feat: Add support and documentation for custom strategies. (#154)

This commit is contained in:
James Harton 2023-01-30 13:16:37 +13:00 committed by GitHub
parent 11cf8a8cfa
commit 7e639e4a21
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1845 additions and 958 deletions

View file

@ -56,3 +56,5 @@ config :ash_authentication,
]
# config :ash_authentication, debug_authentication_failures?: true
config :ash_authentication, extra_strategies: [Example.OnlyMartiesAtTheParty]

View file

@ -36,3 +36,5 @@ config :ash_authentication,
signing_secret: "Marty McFly in the past with the Delorean"
]
]
config :ash_authentication, extra_strategies: [Example.OnlyMartiesAtTheParty]

View file

@ -178,8 +178,29 @@ defmodule DevServer.TestPage do
defp render_strategy(strategy, :callback, _) when strategy.provider == :oauth2, do: ""
defp render_strategy(strategy, :sign_in, _options)
when is_struct(strategy, Example.OnlyMartiesAtTheParty) do
EEx.eval_string(
~s"""
<form method="<%= @method %>" action="<%= @route %>">
<fieldset>
<legend>Sign in a Marty</legend>
<input type="text" name="<%= @strategy.name_field %>" placeholder="<%= @strategy.name_field %>" />
<br />
<input type="submit" value="Sign in" />
</fieldset>
</form>
""",
assigns: [
strategy: strategy,
route: route_for_phase(strategy, :sign_in),
method: Strategy.method_for_phase(strategy, :sign_in)
]
)
end
defp render_strategy(strategy, phase, _options) do
inspect({strategy.provider, phase})
inspect({strategy, phase})
end
defp route_for_phase(strategy, phase) do

View file

@ -0,0 +1,373 @@
# Defining Custom Authentication Strategies
AshAuthentication allows you to bring your own authentication strategy without
having to change the Ash Authenticaiton codebase.
> There is functionally no difference between "add ons" and "strategies" other
> than where they appear in the DSL. We invented "add ons" because it felt
> weird calling "confirmation" an authentication strategy.
There are several moving parts which must all work together so hold on to your hat!
1. A `Spark.Dsl.Entity` struct. This is used to define the strategy DSL
inside the `strategies` (or `add_ons`) section of the `authentication` DSL.
2. A strategy struct, which stores information about the strategy as
configured on a resource which must comply with a few rules.
3. An optional transformer, which can be used to manipulate the DSL state of
the entity and the resource.
4. An optional verifier, which can be used to verify the DSL state of the
entity and the resource after compilation.
4. The `AshAuthentication.Strategy` protocol, which provides the glue needed
for everything to wire up and wrappers around the actions needed to run on
the resource.
5. Runtime configuration of `AshAuthentication` to help it find the extra
strategies.
We're going to define an extremely dumb strategy which lets anyone with a name
that starts with "Marty" sign in with just their name. Of course you would
never do this in real life, but this isn't real life - it's documentation!
## DSL setup
Let's start by defining a module for our strategy to live in. Let's call it
`OnlyMartiesAtTheParty`:
```elixir
defmodule OnlyMartiesAtTheParty do
use AshAuthentication.Strategy.Custom
end
```
Sadly, this isn't enough to make the magic happen. We need to define our DSL
entity by implementing the `dsl/0` callback:
```elixir
defmodule OnlyMartiesAtTheParty do
use AshAuthentication.Strategy.Custom
def dsl do
%Spark.Dsl.Entity{
name: :only_marty,
describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.",
examples: [
"""
only_marty do
case_sensitive? true
name_field :name
end
"""
],
target: __MODULE__,
args: [{:optional, :name, :marty}],
schema: [
name: [
type: :atom,
doc: """
The strategy name.
""",
required: true
],
case_sensitive?: [
type: :boolean,
doc: """
Ignore letter case when comparing?
""",
required: false,
default: false
],
name_field: [
type: :atom,
doc: """
The field to check for the users' name.
""",
required: true
]
]
}
end
end
```
If you haven't you should take a look at the docs for `Spark.Dsl.Entity`, but
here's a brief overview of what each field we've set does:
- `name` is the name for which the helper function will be generated in
the DSL (ie `only_marty do #... end`).
- `describe` and `examples` are used when generating documentation. Probably
worth doing this (and using `Spark.Dsl.Extension.doc_entity/2` to generate
your moduledocs if you plan on sharing this strategy with others).
- `target` is the name of the module which defines our entity struct. We've
set it to `__MODULE__` which means that we'll have to define the struct on
this module.
- `schema` is a keyword list that defines a `NimbleOptions` schema. Spark
provides a number of additional types over the default ones though, so check
out `Spark.OptionsHelpers` for more information.
Next up, we need to define our struct. The struct should have *at least* the
fields named in the entity schema. Additionally, Ash Authentication requires
that it have a `resource` field which will be set to the module of the resource
it's attached to during compilation.
```elixir
defmodule OnlyMartiesAtTheParty do
defstruct name: :marty, case_sensitive?: false, name_field: nil, resource: nil
use AshAuthentication.Strategy.Custom
# other code elided ...
end
```
Now it would be theoretically possible to add this custom strategies to your app
by adding it to the runtime configuration and the user resource:
```elixir
# config.exs
config :ash_authentication, extra_strategies: [OnlyMartiesAtTheParty]
# user resource
defmodule MyApp.Accounts.User do
use Ash.Resource, extensions: [AshAuthentication]
authentication do
api MyApp.Accounts
strategies do
only_marty do
name_field :name
end
end
end
attributes do
uuid_primary_key
attribute :name, :string, allow_nil?: false
end
end
```
## Implementing the `AshAuthentication.Strategy` protocol
The Strategy protocol is used to introspect the strategy so that it can
seamlessly fit in with the rest of Ash Authentication. Here are the key
concepts:
- "phases" - in terms of HTTP, each strategy is likely to have many phases (eg
OAuth 2.0's "request" and "callback" phases). Essentially you need one
phase for each HTTP endpoint you wish to support with your strategy. In our
case we just want one sign in endpoint.
- "actions" - actions are exactly as they sound - Resource actions which can
be executed by the strategy, whether generated by the strategy (as in the
password strategy) or typed in by the user (as in the OAuth 2.0 strategy).
The reason that we wrap the strategy's actions this way is that all the
built-in strategies (and we hope yours too) allow the user to customise the
name of the actions that it uses. At the very least it should probably
append the strategy name to the action. Using `Strategy.action/4` allows us
to refer these by a more generic name rather than via the user-specified one
(eg `:register` vs `:register_with_password`).
- "routes" - `AshAuthentication.Plug` (or `AshAuthentication.Phoenix.Router`)
will generate routes using `Plug.Router` (or `Phoenix.Router`) - the
`routes/1` callback is used to retrieve this information from the strategy.
Given this information, let's implment the strategy. It's quite long, so I'm going to break it up into smaller chunks.
```elixir
defimpl AshAuthentication.Strategy, for: OnlyMartiesAtTheParty do
```
The `name/1` function is used to uniquely identify the strategy. It *must* be an
atom and *should* be the same as the path fragment used in the generated routes.
```elixir
def name(strategy), do: strategy.name
```
Since our strategy only supports sign-in we only need a single `:sign_in` phase
and action.
```elixir
def phases(_), do: [:sign_in]
def actions(_), do: [:sign_in]
```
Next we generate the routes for the strategy. Routes *should* contain the
subject name of the resource being authenticated in case the implementor is
authenticating multiple different resources - eg `User` and `Admin`.
```elixir
def routes(strategy) do
subject_name = Info.authentication_subject_name!(strategy.resource)
[
{"/#{subject_name}/#{strategy.name}", :sign_in}
]
end
```
When generating routes or forms for this phase, what HTTP method should we use?
```elixir
def method_for_phase(_, :sign_in), do: :post
```
Next up, we write our plug. We take the "name field" from the input params in
the conn and pass them to our sign in action. As long as the action returns
`{:ok, Ash.Resource.record}` or `{:error, any}` then we can just pass it
straight into `store_authentication_result/2` from
`AshAuthentication.Plug.Helpers`.
```elixir
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
def plug(strategy, :sign_in, conn) do
params = Map.take(conn.params, [to_string(strategy.name_field)])
result = action(strategy, :sign_in, params, [])
store_authentication_result(conn, result)
end
```
Finally, we implement our sign in action. We use `Ash.Query` to find all
records whose name field matches the input, then constrain it to only records
whose name field starts with "Marty". Depending on whether the name field has a
unique identity on it we have to deal with it returning zero or more users, or
an error. When it returns a single user we return that user in an ok tuple,
otherwise we return an authentication failure.
In this example we're assuming that there is a default `read` action present on
the resource.
> #### Warning {: .warning}
>
> When it comes to authentication, you never want to reveal to the user what the
> failure was - this helps prevent [enumeration
> attacks](https://www.hacksplaining.com/prevention/user-enumeration).
>
> You can use `AshAuthentication.Errors.AuthenticationFailed` for this purpose
> as it will cause `ash_authentication`, `ash_authentication_phoenix`,
> `ash_graphql` and `ash_json_api` to return the correct HTTP 401 error.
```elixir
alias AshAuthentication.Errors.AuthenticationFailed
require Ash.Query
def action(strategy, :sign_in, params, options) when strategy.case_sensitive? do
name_field = strategy.name_field
name = Map.get(params, to_string(name_field))
api = AshAuthentication.Info.authentication_api!(strategy.resource)
strategy.resource
|> Ash.Query.filter(ref(^name_field) == ^name)
|> then(fn query ->
if strategy.case_sensitive? do
Ash.Query.filter(query, like(ref(^name_field), "Marty%"))
else
Ash.Query.filter(query, ilike(ref(^name_field), "Marty%"))
end
end)
|> api.read(options)
|> case do
{:ok, [user]} ->
{:ok, user}
{:ok, []} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: :no_user})}
{:ok, _users} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: :too_many_users})}
{:error, reason} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: reason})}
end
end
end
```
## Bonus round - transformers and verifiers
In some cases it may be required for your strategy to modify it's own
configuration or that of the whole resource at compile time. For that you can
define the `transform/2` callback on your strategy module.
At the very least it is good practice to call
`AshAuthentication.Strategy.Custom.Helpers.register_strategy_actions/3` so that
Ash Authentication can keep track of which actions are related to which
strategies and `AshAuthentication.Strategy.Custom.Helpers` is automatically
imported by `use AshAuthentication.Strategy.Custom` for this purpose.
### Transformers
For simple cases where you're just transforming the strategy you can just return
the modified strategy and the DSL will be updated accordingly. For example if
you wanted to generate the name of an action if the user hasn't specified it:
```elixir
def transform(strategy, _dsl_state) do
{:ok, Map.put_new(strategy, :sign_in_action_name, :"sign_in_with_#{strategy.name}")}
end
```
In some cases you may want to modify the strategy and the resources DSL. In
this case you can return the newly muted DSL state in an ok tuple or an error
tuple, preferably containing a `Spark.Error.DslError`. For example if we
wanted to build a sign in action for `OnlyMartiesAtTheParty` to use:
```elixir
def transform(strategy, dsl_state) do
strategy = Map.put_new(strategy, :sign_in_action_name, :"sign_in_with_#{strategy.name}")
sign_in_action =
Spark.Dsl.Transformer.build_entity(Ash.Resource.Dsl, [:actions], :read,
name: strategy.sign_in_action_name,
accept: [strategy.name_field],
get?: true
)
dsl_state =
dsl_state
|> Spark.Dsl.Transformer.add_entity([:actions], sign_in_action)
|> put_strategy(strategy)
|> then(fn dsl_state ->
register_strategy_actions([strategy.sign_in_action_name], dsl_state, strategy)
end)
{:ok, dsl_state}
end
```
Transformers can also be used to validate user input or even directly add code
to the resource. See the docs for `Spark.Dsl.Transformer` for more information.
### Verifiers
We also support a variant of transformers which run in the new `@after_verify`
compile hook provided by Elixir 1.14. This is a great place to put checks
to make sure that the user's configuration make sense without adding any
compile-time dependencies between modules which may cause compiler deadlocks.
For example, verifying that the "name" attribute contains "marty" (why you would
do this I don't know but I'm running out of sensible examples):
```elixir
def verify(strategy, _dsl_state) do
if String.contains?(to_string(strategy.name_field), "marty") do
:ok
else
{:error,
Spark.Error.DslError.exception(
path: [:authentication, :strategies, :only_marties],
message: "Option `name_field` must contain the \"marty\""
)}
end
end
```
## Summary
You should now have all the tools you need to build custom strategies - and in
fact the strategies provided by Ash Authentication are built using this system.
If there is functionality or documentation missing please [raise an
issue](https://github.com/team-alembic/ash_authentication/issues/new) and we'll
take a look at it.
Go forth and strategise!

View file

@ -101,13 +101,11 @@ defmodule AshAuthentication do
sections: dsl(),
transformers: [
AshAuthentication.Transformer,
AshAuthentication.Strategy.Custom.Transformer
],
verifiers: [
AshAuthentication.Verifier,
AshAuthentication.Strategy.Password.Transformer,
AshAuthentication.Strategy.Password.Verifier,
AshAuthentication.Strategy.OAuth2.Transformer,
AshAuthentication.Strategy.OAuth2.Verifier,
AshAuthentication.AddOn.Confirmation.Transformer,
AshAuthentication.AddOn.Confirmation.Verifier
AshAuthentication.Strategy.Custom.Verifier
]
require Ash.Query

View file

@ -1,5 +1,5 @@
defmodule AshAuthentication.AddOn.Confirmation do
import AshAuthentication.Dsl
alias __MODULE__.{Dsl, Transformer, Verifier}
@moduledoc """
Confirmation support.
@ -82,7 +82,7 @@ defmodule AshAuthentication.AddOn.Confirmation do
## DSL Documentation
#{Spark.Dsl.Extension.doc_entity(strategy(:confirmation))}
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
"""
defstruct token_lifetime: nil,
@ -114,6 +114,10 @@ defmodule AshAuthentication.AddOn.Confirmation do
name: :confirm
}
defdelegate dsl(), to: Dsl
defdelegate transform(strategy, dsl_state), to: Transformer
defdelegate verify(strategy, dsl_state), to: Verifier
@doc """
Generate a confirmation token for a changeset.

View file

@ -12,6 +12,7 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
Errors.InvalidToken,
Info,
Jwt,
Strategy,
TokenResource
}
@ -65,7 +66,7 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
|> Changeset.for_create(store_changes_action, %{
token: token,
extra_data: changes,
purpose: to_string(strategy.name)
purpose: to_string(Strategy.name(strategy))
})
|> api.create(Keyword.merge(opts, upsert?: true)) do
:ok

View file

@ -0,0 +1,119 @@
defmodule AshAuthentication.AddOn.Confirmation.Dsl do
@moduledoc """
Defines the Spark DSL entity for this add on.
"""
alias AshAuthentication.AddOn.Confirmation
alias Spark.Dsl.Entity
@default_confirmation_lifetime_days 3
@doc false
@spec dsl :: map
def dsl do
%Entity{
name: :confirmation,
describe: "User confirmation flow",
args: [{:optional, :name, :confirm}],
target: Confirmation,
modules: [:sender],
schema: [
name: [
type: :atom,
doc: """
Uniquely identifies the add-on.
""",
required: true
],
token_lifetime: [
type: :pos_integer,
doc: """
How long should the confirmation token be valid, in hours.
Defaults to #{@default_confirmation_lifetime_days} days.
""",
default: @default_confirmation_lifetime_days * 24
],
monitor_fields: [
type: {:list, :atom},
doc: """
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.
""",
required: true
],
confirmed_at_field: [
type: :atom,
doc: """
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.
""",
default: :confirmed_at
],
confirm_on_create?: [
type: :boolean,
doc: """
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.
""",
default: true
],
confirm_on_update?: [
type: :boolean,
doc: """
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.
""",
default: true
],
inhibit_updates?: [
type: :boolean,
doc: """
Wait until confirmation is received before actually changing a monitored
field?
If a change to a monitored field is detected, then the change is stored
in the token resource and the changeset updated to not make the
requested change. When the token is confirmed, the change will be
applied.
This could be potentially weird for your users, but useful in the case
of a user changing their email address or phone number where you want
to verify that the new contact details are reachable.
""",
default: true
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.Sender,
{AshAuthentication.SenderFunction, 3}},
doc: """
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.
""",
required: true
],
confirm_action_name: [
type: :atom,
doc: """
The name of the action to use when performing confirmation.
If this action is not already present on the resource, it will be
created for you.
""",
default: :confirm
]
]
}
end
end

View file

@ -14,6 +14,10 @@ defimpl AshAuthentication.Strategy, for: AshAuthentication.AddOn.Confirmation do
@typedoc "The actions supported by this strategy"
@type action :: :confirm
@doc false
@spec name(Confirmation.t()) :: atom
def name(strategy), do: strategy.name
@doc false
@spec phases(Confirmation.t()) :: [phase]
def phases(_), do: [:confirm]

View file

@ -6,9 +6,8 @@ defmodule AshAuthentication.AddOn.Confirmation.Transformer do
configured.
"""
use Spark.Dsl.Transformer
alias Ash.{Resource, Type}
alias AshAuthentication.{AddOn.Confirmation, GenerateTokenChange, Info}
alias AshAuthentication.{AddOn.Confirmation, GenerateTokenChange}
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Utils
import AshAuthentication.Validations
@ -16,38 +15,9 @@ defmodule AshAuthentication.AddOn.Confirmation.Transformer do
import AshAuthentication.Validations.Attribute
@doc false
@impl true
@spec after?(module) :: boolean
def after?(AshAuthentication.Transformer), do: true
def after?(_), do: false
@doc false
@impl true
@spec before?(module) :: boolean
def before?(Resource.Transformers.DefaultAccept), do: true
def before?(_), do: false
@doc false
@impl true
@spec transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
dsl_state
|> Info.authentication_add_ons()
|> Enum.filter(&is_struct(&1, Confirmation))
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
case transform_strategy(strategy, dsl_state) do
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp transform_strategy(strategy, dsl_state) do
@spec transform(Confirmation.t(), map) ::
{:ok, Confirmation.t() | map} | {:error, Exception.t()}
def transform(strategy, dsl_state) do
with :ok <- validate_token_generation_enabled(dsl_state),
:ok <- validate_monitor_fields(dsl_state, strategy),
{:ok, dsl_state} <-

View file

@ -3,47 +3,13 @@ defmodule AshAuthentication.AddOn.Confirmation.Verifier do
DSL verifier for confirmation add-on.
"""
use Spark.Dsl.Transformer
alias AshAuthentication.{AddOn.Confirmation, Info, Sender}
alias AshAuthentication.{AddOn.Confirmation, Sender}
alias Spark.Error.DslError
import AshAuthentication.Validations
@doc false
@impl true
@spec after?(module) :: boolean
def after?(_), do: true
@doc false
@impl true
@spec before?(module) :: boolean
def before?(_), do: false
@doc false
@impl true
@spec after_compile? :: boolean
def after_compile?, do: true
@doc false
@impl true
@spec transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
dsl_state
|> Info.authentication_add_ons()
|> Stream.filter(&is_struct(&1, Confirmation))
|> Enum.reduce_while(:ok, fn strategy, :ok ->
case transform_strategy(strategy) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp transform_strategy(strategy) do
@spec verify(Confirmation.t(), map) :: :ok | {:error, Exception.t()}
def verify(strategy, _dsl_state) do
case Map.fetch(strategy, :sender) do
{:ok, {sender, _opts}} ->
validate_behaviour(sender, Sender)

View file

@ -12,49 +12,31 @@ defmodule AshAuthentication.Dsl do
alias AshAuthentication.{
AddOn.Confirmation,
Strategy.Auth0,
Strategy.Github,
Strategy.OAuth2,
Strategy.Password
}
alias Spark.{
Dsl.Entity,
Dsl.Section,
OptionsHelpers
}
@type strategy :: :confirmation | :oauth2 | :password | :auth0 | :github
@shared_strategy_options [
name: [
type: :atom,
doc: """
Uniquely identifies the strategy.
""",
required: true
]
]
@shared_addon_options [
name: [
type: :atom,
doc: """
Uniquely identifies the add-on.
""",
required: true
]
]
@default_token_lifetime_days 14
@default_confirmation_lifetime_days 3
@secret_type {:or,
alias Spark.Dsl.Section
@doc false
@spec secret_type :: any
def secret_type,
do:
{:or,
[
{:spark_function_behaviour, AshAuthentication.Secret,
{AshAuthentication.SecretFunction, 2}},
:string
]}
@secret_doc """
@doc false
@spec secret_doc :: String.t()
def secret_doc,
do: """
Takes either a module which implements the `AshAuthentication.Secret`
behaviour, a 2 arity anonymous function or a string.
@ -65,6 +47,9 @@ defmodule AshAuthentication.Dsl do
@doc false
@spec dsl :: [Section.t()]
def dsl do
secret_type = secret_type()
secret_doc = secret_doc()
[
%Section{
name: :authentication,
@ -178,11 +163,11 @@ defmodule AshAuthentication.Dsl do
required: true
],
signing_secret: [
type: @secret_type,
type: secret_type,
doc: """
The secret used to sign tokens.
#{@secret_doc}
#{secret_doc}
"""
]
]
@ -190,595 +175,37 @@ defmodule AshAuthentication.Dsl do
%Section{
name: :strategies,
describe: "Configure authentication strategies on this resource",
entities: [
strategy(:password),
strategy(:oauth2),
strategy(:auth0),
strategy(:github)
]
entities: Enum.map(available_strategies(), & &1.dsl())
},
%Section{
name: :add_ons,
describe: "Additional add-ons related to, but not providing authentication",
entities: [
strategy(:confirmation)
]
entities: Enum.map(available_add_ons(), & &1.dsl())
}
]
}
]
end
# The result spec should be changed to `Entity.t` when Spark 0.2.18 goes out.
@doc false
@spec strategy(strategy) :: map
def strategy(:password) do
%Entity{
name: :password,
describe: "Strategy for authenticating using local resources as the source of truth.",
examples: [
@doc """
Return the available strategy modules.
This is used for DSL generation and transformation.
"""
password :password do
identity_field :email
hashed_password_field :hashed_password
hash_provider AshAuthentication.BcryptProvider
confirmation_required? true
@spec available_strategies :: [module]
def available_strategies do
[Auth0, Github, OAuth2, Password]
|> Enum.concat(Application.get_env(:ash_authentication, :extra_strategies, []))
end
@doc """
Return the available add-on modules.
This is used for DSL generation and transformation.
"""
],
args: [{:optional, :name, :password}],
hide: [:name],
target: Password,
modules: [:hash_provider],
schema:
OptionsHelpers.merge_schemas(
[
identity_field: [
type: :atom,
doc: """
The name of the attribute which uniquely identifies the user.
Usually something like `username` or `email_address`.
""",
default: :username
],
hashed_password_field: [
type: :atom,
doc: """
The name of the attribute within which to store the user's password
once it has been hashed.
""",
default: :hashed_password
],
hash_provider: [
type: {:behaviour, AshAuthentication.HashProvider},
doc: """
A module which implements the `AshAuthentication.HashProvider`
behaviour.
Used to provide cryptographic hashing of passwords.
""",
default: AshAuthentication.BcryptProvider
],
confirmation_required?: [
type: :boolean,
required: false,
doc: """
Whether a password confirmation field is required when registering or
changing passwords.
""",
default: true
],
password_field: [
type: :atom,
doc: """
The name of the argument used to collect the user's password in
plaintext when registering, checking or changing passwords.
""",
default: :password
],
password_confirmation_field: [
type: :atom,
doc: """
The name of the argument used to confirm the user's password in
plaintext when registering or changing passwords.
""",
default: :password_confirmation
],
register_action_name: [
type: :atom,
doc: """
The name to use for the register action.
If not present it will be generated by prepending the strategy name
with `register_with_`.
""",
required: false
],
sign_in_action_name: [
type: :atom,
doc: """
The name to use for the sign in action.
If not present it will be generated by prependign the strategy name
with `sign_in_with_`.
""",
required: false
]
],
@shared_strategy_options,
"Shared options"
),
entities: [resettable: [Password.Resettable.entity()]]
}
end
def strategy(:oauth2) do
%Entity{
name: :oauth2,
describe: "OAuth2 authentication",
args: [{:optional, :name, :oauth2}],
target: OAuth2,
modules: [
:authorize_url,
:client_id,
:client_secret,
:identity_resource,
:private_key,
:redirect_uri,
:site,
:token_url,
:user_url
],
schema:
OptionsHelpers.merge_schemas(
[
client_id: [
type: @secret_type,
doc: """
The OAuth2 client ID.
#{@secret_doc}
Example:
```elixir
client_id fn _, resource ->
:my_app
|> Application.get_env(resource, [])
|> Keyword.fetch(:oauth_client_id)
end
```
""",
required: true
],
site: [
type: @secret_type,
doc: """
The base URL of the OAuth2 server - including the leading protocol
(ie `https://`).
#{@secret_doc}
Example:
```elixir
site fn _, resource ->
:my_app
|> Application.get_env(resource, [])
|> Keyword.fetch(:oauth_site)
end
```
""",
required: true
],
auth_method: [
type:
{:in,
[
nil,
:client_secret_basic,
:client_secret_post,
:client_secret_jwt,
:private_key_jwt
]},
doc: """
The authentication strategy used, optional. If not set, no
authentication will be used during the access token request. The
value may be one of the following:
* `:client_secret_basic`
* `:client_secret_post`
* `:client_secret_jwt`
* `:private_key_jwt`
""",
default: :client_secret_post
],
client_secret: [
type: @secret_type,
doc: """
The OAuth2 client secret.
Required if :auth_method is `:client_secret_basic`,
`:client_secret_post` or `:client_secret_jwt`.
#{@secret_doc}
Example:
```elixir
site fn _, resource ->
:my_app
|> Application.get_env(resource, [])
|> Keyword.fetch(:oauth_site)
end
```
""",
required: false
],
authorize_url: [
type: @secret_type,
doc: """
The API url to the OAuth2 authorize endpoint.
Relative to the value of `site`.
#{@secret_doc}
Example:
```elixir
authorize_url fn _, _ -> {:ok, "https://exampe.com/authorize"} end
```
""",
required: true
],
token_url: [
type: @secret_type,
doc: """
The API url to access the token endpoint.
Relative to the value of `site`.
#{@secret_doc}
Example:
```elixir
token_url fn _, _ -> {:ok, "https://example.com/oauth_token"} end
```
""",
required: true
],
user_url: [
type: @secret_type,
doc: """
The API url to access the user endpoint.
Relative to the value of `site`.
#{@secret_doc}
Example:
```elixir
user_url fn _, _ -> {:ok, "https://example.com/userinfo"} end
```
""",
required: true
],
private_key: [
type: @secret_type,
doc: """
The private key to use if `:auth_method` is `:private_key_jwt`
#{@secret_doc}
""",
required: false
],
redirect_uri: [
type: @secret_type,
doc: """
The callback URI base.
Not the whole URI back to the callback endpoint, but the URI to your
`AuthPlug`. We can generate the rest.
Whilst not particularly secret, it seemed prudent to allow this to be
configured dynamically so that you can use different URIs for
different environments.
#{@secret_doc}
""",
required: true
],
authorization_params: [
type: :keyword_list,
doc: """
Any additional parameters to encode in the request phase.
eg: `authorization_params scope: "openid profile email"`
""",
default: []
],
registration_enabled?: [
type: :boolean,
doc: """
Is registration enabled for this provider?
If this option is enabled, then new users will be able to register for
your site when authenticating and not already present.
If not, then only existing users will be able to authenticate.
""",
default: true
],
register_action_name: [
type: :atom,
doc: ~S"""
The name of the action to use to register a user.
Only needed if `registration_enabled?` is `true`.
Because we we don't know the response format of the server, you must
implement your own registration action of the same name.
See the "Registration and Sign-in" section of the module
documentation for more information.
The default is computed from the strategy name eg:
`register_with_#{name}`.
""",
required: false
],
sign_in_action_name: [
type: :atom,
doc: ~S"""
The name of the action to use to sign in an existing user.
Only needed if `registration_enabled?` is `false`.
Because we don't know the response format of the server, you must
implement your own sign-in action of the same name.
See the "Registration and Sign-in" section of the module
documentation for more information.
The default is computed from the strategy name, eg:
`sign_in_with_#{name}`.
""",
required: false
],
identity_resource: [
type: {:or, [{:behaviour, Ash.Resource}, {:in, [false]}]},
doc: """
The resource used to store user identities.
Given that a user can be signed into multiple different
authentication providers at once we use the
`AshAuthentication.UserIdentity` resource to build a mapping
between users, providers and that provider's uid.
See the Identities section of the module documentation for more
information.
Set to `false` to disable.
""",
default: false
],
identity_relationship_name: [
type: :atom,
doc: "Name of the relationship to the provider identities resource",
default: :identities
],
identity_relationship_user_id_attribute: [
type: :atom,
doc: """
The name of the destination (user_id) attribute on your provider
identity resource.
The only reason to change this would be if you changed the
`user_id_attribute_name` option of the provider identity.
""",
default: :user_id
]
],
@shared_strategy_options,
"Shared options"
),
auto_set_fields: [assent_strategy: Assent.Strategy.OAuth2]
}
end
def strategy(:confirmation) do
%Entity{
name: :confirmation,
describe: "User confirmation flow",
args: [{:optional, :name, :confirm}],
target: Confirmation,
modules: [:sender],
schema:
OptionsHelpers.merge_schemas(
[
token_lifetime: [
type: :pos_integer,
doc: """
How long should the confirmation token be valid, in hours.
Defaults to #{@default_confirmation_lifetime_days} days.
""",
default: @default_confirmation_lifetime_days * 24
],
monitor_fields: [
type: {:list, :atom},
doc: """
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.
""",
required: true
],
confirmed_at_field: [
type: :atom,
doc: """
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.
""",
default: :confirmed_at
],
confirm_on_create?: [
type: :boolean,
doc: """
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.
""",
default: true
],
confirm_on_update?: [
type: :boolean,
doc: """
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.
""",
default: true
],
inhibit_updates?: [
type: :boolean,
doc: """
Wait until confirmation is received before actually changing a monitored
field?
If a change to a monitored field is detected, then the change is stored
in the token resource and the changeset updated to not make the
requested change. When the token is confirmed, the change will be
applied.
This could be potentially weird for your users, but useful in the case
of a user changing their email address or phone number where you want
to verify that the new contact details are reachable.
""",
default: true
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.Sender,
{AshAuthentication.SenderFunction, 3}},
doc: """
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.
""",
required: true
],
confirm_action_name: [
type: :atom,
doc: """
The name of the action to use when performing confirmation.
If this action is not already present on the resource, it will be
created for you.
""",
default: :confirm
]
],
@shared_addon_options,
"Shared options"
)
}
end
def strategy(:auth0) do
:oauth2
|> strategy()
|> Map.merge(%{
name: :auth0,
args: [{:optional, :name, :auth0}],
describe: """
Provides a pre-configured authentication strategy for [Auth0](https://auth0.com/).
This strategy is built using `:oauth2` strategy, and thus provides all the same
configuration options should you need them.
For more information see the [Auth0 Quick Start Guide](/documentation/tutorials/auth0-quickstart.md)
in our documentation.
#### Strategy defaults:
#{strategy_override_docs(Assent.Strategy.Auth0)}
#### Schema:
""",
auto_set_fields: strategy_fields(Assent.Strategy.Auth0, icon: :auth0)
})
end
def strategy(:github) do
:oauth2
|> strategy()
|> Map.merge(%{
name: :github,
args: [{:optional, :name, :github}],
describe: """
Provides a pre-configured authentication strategy for [GitHub](https://github.com/).
This strategy is built using `:oauth2` strategy, and thus provides all the same
configuration options should you need them.
For more information see the [Github Quick Start Guide](/documentation/tutorials/github-quickstart.md)
in our documentation.
#### Strategy defaults:
#{strategy_override_docs(Assent.Strategy.Github)}
#### Schema:
""",
auto_set_fields: strategy_fields(Assent.Strategy.Github, icon: :github)
})
end
defp strategy_fields(strategy, params) do
[]
|> strategy.default_config()
|> Keyword.put(:assent_strategy, strategy)
|> Keyword.merge(params)
end
defp strategy_override_docs(strategy) do
defaults =
[]
|> strategy.default_config()
|> Enum.map_join(
".\n",
fn {key, value} ->
" * `#{inspect(key)}` is set to `#{inspect(value)}`"
end
)
"""
The following defaults are applied:
#{defaults}.
"""
@spec available_add_ons :: [module]
def available_add_ons do
[Confirmation]
|> Enum.concat(Application.get_env(:ash_authentication, :extra_add_ons, []))
end
end

View file

@ -22,7 +22,7 @@ defmodule AshAuthentication.Info do
|> authentication_strategies()
|> Stream.concat(authentication_add_ons(dsl_or_resource))
|> Enum.find_value(:error, fn strategy ->
if strategy.name == name, do: {:ok, strategy}
if Strategy.name(strategy) == name, do: {:ok, strategy}
end)
end

View file

@ -23,7 +23,7 @@ defmodule AshAuthentication.Plug.Dispatcher do
@impl true
@spec call(Conn.t(), config | any) :: Conn.t()
def call(conn, {phase, strategy, return_to}) do
activity = {strategy.name, phase}
activity = {Strategy.name(strategy), phase}
strategy
|> Strategy.plug(phase, conn)

View file

@ -0,0 +1,76 @@
defmodule AshAuthentication.Strategy.Auth0 do
@moduledoc """
Strategy for authenticating using [Auth0](https://auth0.com).
This strategy builds on-top of `AshAuthentication.Strategy.OAuth2` and
[`assent`](https://hex.pm/packages/assent).
In order to use Auth0 you need to provide the following minimum configuration:
- `client_id`
- `redirect_uri`
- `client_secret`
- `site`
See the [Auth0 quickstart guide](/documentation/tutorials/auth0-quickstart.html)
for more information.
"""
alias AshAuthentication.Strategy.{Custom, OAuth2}
use Custom
@doc false
# credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct
@spec dsl :: Custom.entity()
def dsl do
OAuth2.dsl()
|> Map.merge(%{
name: :auth0,
args: [{:optional, :name, :auth0}],
describe: """
Provides a pre-configured authentication strategy for [Auth0](https://auth0.com/).
This strategy is built using `:oauth2` strategy, and thus provides all the same
configuration options should you need them.
For more information see the [Auth0 Quick Start Guide](/documentation/tutorials/auth0-quickstart.md)
in our documentation.
#### Strategy defaults:
#{strategy_override_docs(Assent.Strategy.Auth0)}
#### Schema:
""",
auto_set_fields: strategy_fields(Assent.Strategy.Auth0, icon: :auth0)
})
end
defdelegate transform(strategy, dsl_state), to: OAuth2
defdelegate verify(strategy, dsl_state), to: OAuth2
defp strategy_fields(strategy, params) do
[]
|> strategy.default_config()
|> Keyword.put(:assent_strategy, strategy)
|> Keyword.merge(params)
end
defp strategy_override_docs(strategy) do
defaults =
[]
|> strategy.default_config()
|> Enum.map_join(
".\n",
fn {key, value} ->
" * `#{inspect(key)}` is set to `#{inspect(value)}`"
end
)
"""
The following defaults are applied:
#{defaults}.
"""
end
end

View file

@ -0,0 +1,75 @@
defmodule AshAuthentication.Strategy.Custom do
@moduledoc """
Define your own custom authentication strategy.
See [the Custom Strategies guide](/documentation/topics/custom-strategy.html)
for more information.
"""
alias Spark.Dsl
@typedoc """
A Strategy DSL Entity.
See `Spark.Dsl.Entity` for more information.
"""
# credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct
@type entity :: %Dsl.Entity{}
@type strategy :: struct
@doc """
A callback which allows the strategy to provide it's own DSL-based
configuration.
"""
@callback dsl :: entity
@doc """
If your strategy needs to modify either the entity or the parent resource then
you can implement this callback.
This callback can return one of three results:
- `{:ok, Entity.t}` - an updated DSL entity - useful if you're just changing
the entity itself and not changing the wider DSL state of the resource.
If this is the response then the transformer will take care of updating
the entity in the DSL state.
- `{:ok, Dsl.t}` - an updated DSL state for the entire resource.
- `{:error, Exception.t}` - a compilation-stopping problem was found. Any
exception can be returned, but we strongly advise you to return a
`Spark.Error.DslError`.
"""
@callback transform(strategy, Dsl.t()) ::
{:ok, strategy} | {:ok, Dsl.t()} | {:error, Exception.t()}
@doc """
If your strategy needs to verify either the entity or the parent resource then
you can implement this callback.
This is called post-compilation in the `@after_verify` hook - see `Module` for
more information.
This callback can return one of the following results:
- `:ok` - everything is A-Okay.
- `{:error, Exception.t}` - a compilation-stopping problem was found. Any
exception can be returned, but we strongly advise you to return a
`Spark.Error.DslError`.
"""
@callback verify(strategy, Dsl.t()) :: :ok | {:error, Exception.t()}
@doc false
@spec __using__(keyword) :: Macro.t()
defmacro __using__(_opts) do
quote generated: true do
@behaviour unquote(__MODULE__)
import unquote(__MODULE__).Helpers
def transform(entity, _dsl_state), do: {:ok, entity}
def verify(_entity, _dsl_state), do: :ok
defoverridable transform: 2, verify: 2
end
end
end

View file

@ -0,0 +1,57 @@
defmodule AshAuthentication.Strategy.Custom.Helpers do
@moduledoc """
Helpers for use within custom strategies.
"""
alias AshAuthentication.{Strategy, Strategy.Custom}
alias Spark.Dsl.Transformer
@doc """
If there's any chance that an implementor may try and use actions genrated by
your strategy programatically then you should register your actions with Ash
Authentication so that it can find the appropriate strategy when needed.
The strategy can be retrieved again by calling
`AshAuthentication.Info.strategy_for_action/2`.
This helper should only be used within transformers.
"""
@spec register_strategy_actions(action_or_actions, dsl_state, Custom.strategy()) :: dsl_state
when dsl_state: map, action_or_actions: atom | [atom]
def register_strategy_actions(action, dsl_state, strategy) when is_atom(action),
do: register_strategy_actions([action], dsl_state, strategy)
def register_strategy_actions(actions, dsl_state, strategy),
do:
Enum.reduce(
actions,
dsl_state,
&Transformer.persist(&2, {:authentication_action, &1}, strategy)
)
@doc """
Update the strategy in the DSL state by name.
This helper should only be used within transformers.
"""
@spec put_strategy(dsl_state, Custom.strategy()) :: dsl_state when dsl_state: map
def put_strategy(dsl_state, strategy),
do: put_entity(dsl_state, strategy, ~w[authentication strategies]a)
@doc """
Update the add-on in the DSL state by name.
This helper should only be used within transformers.
"""
@spec put_add_on(dsl_state, Custom.strategy()) :: dsl_state when dsl_state: map
def put_add_on(dsl_state, strategy),
do: put_entity(dsl_state, strategy, ~w[authentication strategies]a)
defp put_entity(dsl_state, strategy, path) do
name = Strategy.name(strategy)
dsl_state
|> Transformer.remove_entity(path, &(Strategy.name(&1) == name))
|> Transformer.add_entity(path, strategy)
end
end

View file

@ -0,0 +1,122 @@
defmodule AshAuthentication.Strategy.Custom.Transformer do
@moduledoc """
Transformer used by custom strategies.
It delegates transformation passes to the individual strategies.
"""
use Spark.Dsl.Transformer
alias AshAuthentication.{Dsl, Info, Strategy}
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Strategy.Custom.Helpers
@doc false
@impl true
@spec after?(module) :: boolean
def after?(AshAuthentication.Transformer), do: true
def after?(_), do: false
@doc false
@impl true
@spec before?(module) :: boolean
def before?(Resource.Transformers.DefaultAccept), do: true
def before?(_), do: false
@doc false
@impl true
@spec transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
strategy_modules =
Dsl.available_add_ons()
|> Stream.concat(Dsl.available_strategies())
|> Enum.map(&{&1.dsl().target, &1})
|> Map.new()
with {:ok, dsl_state} <- do_strategy_transforms(dsl_state, strategy_modules) do
do_add_on_transforms(dsl_state, strategy_modules)
end
end
defp do_strategy_transforms(dsl_state, strategy_modules) do
dsl_state
|> Info.authentication_strategies()
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
strategy_module = Map.fetch!(strategy_modules, strategy.__struct__)
case do_transform(strategy_module, strategy, dsl_state, :strategy) do
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp do_add_on_transforms(dsl_state, strategy_modules) do
dsl_state
|> Info.authentication_add_ons()
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
strategy_module = Map.fetch!(strategy_modules, strategy.__struct__)
case do_transform(strategy_module, strategy, dsl_state, :add_on) do
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp do_transform(strategy_module, strategy, dsl_state, :strategy)
when is_map_key(strategy, :resource) do
strategy = %{strategy | resource: Transformer.get_persisted(dsl_state, :module)}
dsl_state = put_strategy(dsl_state, strategy)
entity_module = strategy.__struct__
strategy
|> strategy_module.transform(dsl_state)
|> case do
{:ok, strategy} when is_struct(strategy, entity_module) ->
{:ok, put_strategy(dsl_state, strategy)}
{:ok, dsl_state} when is_map(dsl_state) ->
{:ok, dsl_state}
{:error, reason} ->
{:error, reason}
end
end
defp do_transform(strategy_module, strategy, dsl_state, :add_on)
when is_map_key(strategy, :resource) do
strategy = %{strategy | resource: Transformer.get_persisted(dsl_state, :module)}
dsl_state = put_add_on(dsl_state, strategy)
entity_module = strategy.__struct__
strategy
|> strategy_module.transform(dsl_state)
|> case do
{:ok, strategy} when is_struct(strategy, entity_module) ->
{:ok, put_add_on(dsl_state, strategy)}
{:ok, dsl_state} when is_map(dsl_state) ->
{:ok, dsl_state}
{:error, reason} ->
{:error, reason}
end
end
defp do_transform(_strategy_module, strategy, _, _) do
name = Strategy.name(strategy)
{:error,
DslError.exception(
path: [:authentication, name],
message:
"The struct defined by `#{inspect(strategy.__struct__)}` must contain a `resource` field."
)}
end
end

View file

@ -0,0 +1,39 @@
defmodule AshAuthentication.Strategy.Custom.Verifier do
@moduledoc """
Verifier used by custom strategies.
It delegates verification passes to the individual strategies.
"""
use Spark.Dsl.Verifier
alias AshAuthentication.{Dsl, Info}
@doc false
@impl true
@spec verify(map) ::
:ok
| {:error, term}
| {:warn, String.t() | list(String.t())}
def verify(dsl_state) do
strategy_modules =
Dsl.available_add_ons()
|> Stream.concat(Dsl.available_strategies())
|> Enum.map(&{&1.dsl().target, &1})
|> Map.new()
dsl_state
|> Info.authentication_strategies()
|> Stream.concat(Info.authentication_add_ons(dsl_state))
|> Enum.reduce_while(:ok, fn strategy, :ok ->
strategy_module = Map.fetch!(strategy_modules, strategy.__struct__)
strategy
|> strategy_module.verify(dsl_state)
|> case do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
end

View file

@ -0,0 +1,75 @@
defmodule AshAuthentication.Strategy.Github do
@moduledoc """
Strategy for authenticating using [GitHub](https://github.com)
This strategy builds on-top of `AshAuthentication.Strategy.OAuth2` and
[`assent`](https://hex.pm/packages/assent).
In order to use GitHub you need to provide the following minimum configuration:
- `client_id`
- `redirect_uri`
- `client_secret`
See the [GitHub quickstart guide](/documentation/tutorials/github-quickstart.html)
for more information.
"""
alias AshAuthentication.Strategy.{Custom, OAuth2}
use Custom
@doc false
# credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct
@spec dsl :: Custom.entity()
def dsl do
OAuth2.dsl()
|> Map.merge(%{
name: :github,
args: [{:optional, :name, :github}],
describe: """
Provides a pre-configured authentication strategy for [GitHub](https://github.com/).
This strategy is built using `:oauth2` strategy, and thus provides all the same
configuration options should you need them.
For more information see the [Github Quick Start Guide](/documentation/tutorials/github-quickstart.md)
in our documentation.
#### Strategy defaults:
#{strategy_override_docs(Assent.Strategy.Github)}
#### Schema:
""",
auto_set_fields: strategy_fields(Assent.Strategy.Github, icon: :github)
})
end
defdelegate transform(strategy, dsl_state), to: OAuth2
defdelegate verify(strategy, dsl_state), to: OAuth2
defp strategy_fields(strategy, params) do
[]
|> strategy.default_config()
|> Keyword.put(:assent_strategy, strategy)
|> Keyword.merge(params)
end
defp strategy_override_docs(strategy) do
defaults =
[]
|> strategy.default_config()
|> Enum.map_join(
".\n",
fn {key, value} ->
" * `#{inspect(key)}` is set to `#{inspect(value)}`"
end
)
"""
The following defaults are applied:
#{defaults}.
"""
end
end

View file

@ -1,5 +1,5 @@
defmodule AshAuthentication.Strategy.OAuth2 do
import AshAuthentication.Dsl
alias __MODULE__.{Dsl, Transformer, Verifier}
@moduledoc """
Strategy for authenticating using an OAuth 2.0 server as the source of truth.
@ -216,7 +216,7 @@ defmodule AshAuthentication.Strategy.OAuth2 do
## DSL Documentation
#{Spark.Dsl.Extension.doc_entity(strategy(:oauth2))}
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
"""
defstruct client_id: nil,
@ -241,6 +241,8 @@ defmodule AshAuthentication.Strategy.OAuth2 do
icon: nil,
assent_strategy: Assent.Strategy.OAuth2
use AshAuthentication.Strategy.Custom
alias AshAuthentication.Strategy.OAuth2
@type secret :: nil | String.t() | {module, keyword}
@ -273,4 +275,8 @@ defmodule AshAuthentication.Strategy.OAuth2 do
icon: nil | atom,
assent_strategy: module
}
defdelegate dsl, to: Dsl
defdelegate transform(strategy, dsl_state), to: Transformer
defdelegate verify(strategy, dsl_state), to: Verifier
end

View file

@ -0,0 +1,292 @@
defmodule AshAuthentication.Strategy.OAuth2.Dsl do
@moduledoc """
Defines the Spark DSL entity for this strategy.
"""
alias AshAuthentication.Strategy.{Custom, OAuth2}
alias Spark.Dsl.Entity
@doc false
@spec dsl :: Custom.entity()
def dsl do
secret_type = AshAuthentication.Dsl.secret_type()
secret_doc = AshAuthentication.Dsl.secret_doc()
%Entity{
name: :oauth2,
describe: "OAuth2 authentication",
args: [{:optional, :name, :oauth2}],
target: OAuth2,
modules: [
:authorize_url,
:client_id,
:client_secret,
:identity_resource,
:private_key,
:redirect_uri,
:site,
:token_url,
:user_url
],
schema: [
name: [
type: :atom,
doc: """
Uniquely identifies the strategy.
""",
required: true
],
client_id: [
type: secret_type,
doc: """
The OAuth2 client ID.
#{secret_doc}
Example:
```elixir
client_id fn _, resource ->
:my_app
|> Application.get_env(resource, [])
|> Keyword.fetch(:oauth_client_id)
end
```
""",
required: true
],
site: [
type: secret_type,
doc: """
The base URL of the OAuth2 server - including the leading protocol
(ie `https://`).
#{secret_doc}
Example:
```elixir
site fn _, resource ->
:my_app
|> Application.get_env(resource, [])
|> Keyword.fetch(:oauth_site)
end
```
""",
required: true
],
auth_method: [
type:
{:in,
[
nil,
:client_secret_basic,
:client_secret_post,
:client_secret_jwt,
:private_key_jwt
]},
doc: """
The authentication strategy used, optional. If not set, no
authentication will be used during the access token request. The
value may be one of the following:
* `:client_secret_basic`
* `:client_secret_post`
* `:client_secret_jwt`
* `:private_key_jwt`
""",
default: :client_secret_post
],
client_secret: [
type: secret_type,
doc: """
The OAuth2 client secret.
Required if :auth_method is `:client_secret_basic`,
`:client_secret_post` or `:client_secret_jwt`.
#{secret_doc}
Example:
```elixir
site fn _, resource ->
:my_app
|> Application.get_env(resource, [])
|> Keyword.fetch(:oauth_site)
end
```
""",
required: false
],
authorize_url: [
type: secret_type,
doc: """
The API url to the OAuth2 authorize endpoint.
Relative to the value of `site`.
#{secret_doc}
Example:
```elixir
authorize_url fn _, _ -> {:ok, "https://exampe.com/authorize"} end
```
""",
required: true
],
token_url: [
type: secret_type,
doc: """
The API url to access the token endpoint.
Relative to the value of `site`.
#{secret_doc}
Example:
```elixir
token_url fn _, _ -> {:ok, "https://example.com/oauth_token"} end
```
""",
required: true
],
user_url: [
type: secret_type,
doc: """
The API url to access the user endpoint.
Relative to the value of `site`.
#{secret_doc}
Example:
```elixir
user_url fn _, _ -> {:ok, "https://example.com/userinfo"} end
```
""",
required: true
],
private_key: [
type: secret_type,
doc: """
The private key to use if `:auth_method` is `:private_key_jwt`
#{secret_doc}
""",
required: false
],
redirect_uri: [
type: secret_type,
doc: """
The callback URI base.
Not the whole URI back to the callback endpoint, but the URI to your
`AuthPlug`. We can generate the rest.
Whilst not particularly secret, it seemed prudent to allow this to be
configured dynamically so that you can use different URIs for
different environments.
#{secret_doc}
""",
required: true
],
authorization_params: [
type: :keyword_list,
doc: """
Any additional parameters to encode in the request phase.
eg: `authorization_params scope: "openid profile email"`
""",
default: []
],
registration_enabled?: [
type: :boolean,
doc: """
Is registration enabled for this provider?
If this option is enabled, then new users will be able to register for
your site when authenticating and not already present.
If not, then only existing users will be able to authenticate.
""",
default: true
],
register_action_name: [
type: :atom,
doc: ~S"""
The name of the action to use to register a user.
Only needed if `registration_enabled?` is `true`.
Because we we don't know the response format of the server, you must
implement your own registration action of the same name.
See the "Registration and Sign-in" section of the module
documentation for more information.
The default is computed from the strategy name eg:
`register_with_#{name}`.
""",
required: false
],
sign_in_action_name: [
type: :atom,
doc: ~S"""
The name of the action to use to sign in an existing user.
Only needed if `registration_enabled?` is `false`.
Because we don't know the response format of the server, you must
implement your own sign-in action of the same name.
See the "Registration and Sign-in" section of the module
documentation for more information.
The default is computed from the strategy name, eg:
`sign_in_with_#{name}`.
""",
required: false
],
identity_resource: [
type: {:or, [{:behaviour, Ash.Resource}, {:in, [false]}]},
doc: """
The resource used to store user identities.
Given that a user can be signed into multiple different
authentication providers at once we use the
`AshAuthentication.UserIdentity` resource to build a mapping
between users, providers and that provider's uid.
See the Identities section of the module documentation for more
information.
Set to `false` to disable.
""",
default: false
],
identity_relationship_name: [
type: :atom,
doc: "Name of the relationship to the provider identities resource",
default: :identities
],
identity_relationship_user_id_attribute: [
type: :atom,
doc: """
The name of the destination (user_id) attribute on your provider
identity resource.
The only reason to change this would be if you changed the
`user_id_attribute_name` option of the provider identity.
""",
default: :user_id
]
],
auto_set_fields: [assent_strategy: Assent.Strategy.OAuth2]
}
end
end

View file

@ -4,7 +4,7 @@ defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
"""
use Ash.Resource.Change
alias AshAuthentication.{Info, UserIdentity}
alias AshAuthentication.{Info, Strategy, UserIdentity}
alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
import AshAuthentication.Utils, only: [is_falsy: 1]
@ -33,7 +33,7 @@ defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
|> UserIdentity.Actions.upsert(%{
user_info: Changeset.get_argument(changeset, :user_info),
oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens),
strategy: strategy.name,
strategy: Strategy.name(strategy),
user_id: user.id
})
|> case do

View file

@ -14,6 +14,10 @@ defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.OAuth2 do
@typedoc "The actions supported by this strategy"
@type action :: :register | :sign_in
@doc false
@spec name(OAuth2.t()) :: atom
def name(strategy), do: strategy.name
@doc false
@spec phases(OAuth2.t()) :: [phase]
def phases(_), do: [:request, :callback]

View file

@ -6,47 +6,17 @@ defmodule AshAuthentication.Strategy.OAuth2.Transformer do
actions and settings are in place.
"""
use Spark.Dsl.Transformer
alias Ash.{Resource, Type}
alias AshAuthentication.{GenerateTokenChange, Info, Strategy.OAuth2}
alias AshAuthentication.{GenerateTokenChange, Info, Strategy, Strategy.OAuth2}
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Strategy.Custom.Helpers
import AshAuthentication.Utils
import AshAuthentication.Validations
import AshAuthentication.Validations.Action
@doc false
@impl true
@spec after?(module) :: boolean
def after?(AshAuthentication.Transformer), do: true
def after?(_), do: false
@doc false
@impl true
@spec before?(module) :: boolean
def before?(Resource.Transformers.DefaultAccept), do: true
def before?(_), do: false
@doc false
@impl true
@spec transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
dsl_state
|> Info.authentication_strategies()
|> Stream.filter(&is_struct(&1, OAuth2))
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
case transform_strategy(strategy, dsl_state) do
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp transform_strategy(strategy, dsl_state) do
@spec transform(OAuth2.t(), map) :: {:ok, OAuth2.t() | map} | {:error, Exception.t()}
def transform(strategy, dsl_state) do
with strategy <- set_defaults(strategy),
{:ok, dsl_state} <- maybe_build_identity_relationship(dsl_state, strategy),
:ok <- maybe_validate_register_action(dsl_state, strategy),
@ -59,15 +29,12 @@ defmodule AshAuthentication.Strategy.OAuth2.Transformer do
|> Transformer.replace_entity(
~w[authentication strategies]a,
strategy,
&(&1.name == strategy.name)
&(Strategy.name(&1) == strategy.name)
)
|> then(fn dsl_state ->
~w[register_action_name sign_in_action_name]a
|> Stream.map(&Map.get(strategy, &1))
|> Enum.reduce(
dsl_state,
&Transformer.persist(&2, {:authentication_action, &1}, strategy)
)
|> Enum.map(&Map.get(strategy, &1))
|> register_strategy_actions(dsl_state, strategy)
end)
{:ok, dsl_state}

View file

@ -3,47 +3,13 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do
DSL verifier for oauth2 strategies.
"""
use Spark.Dsl.Transformer
alias AshAuthentication.{Info, Strategy.OAuth2}
alias AshAuthentication.{Secret, Strategy.OAuth2}
alias Spark.Error.DslError
import AshAuthentication.Validations
@doc false
@impl true
@spec after?(module) :: boolean
def after?(_), do: true
@doc false
@impl true
@spec before?(module) :: boolean
def before?(_), do: false
@doc false
@impl true
@spec after_compile? :: boolean
def after_compile?, do: true
@doc false
@impl true
@spec transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
dsl_state
|> Info.authentication_strategies()
|> Stream.filter(&is_struct(&1, OAuth2))
|> Enum.reduce_while(:ok, fn strategy, :ok ->
case transform_strategy(strategy) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp transform_strategy(strategy) do
@spec verify(OAuth2.t(), map) :: :ok | {:error, Exception.t()}
def verify(strategy, _dsl_state) do
with :ok <- validate_secret(strategy, :authorize_url),
:ok <- validate_secret(strategy, :client_id),
:ok <- validate_secret(strategy, :client_secret),
@ -64,7 +30,7 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do
:ok
{:ok, {module, _}} when is_atom(module) ->
validate_behaviour(module, AshAuthentication.Secret)
validate_behaviour(module, Secret)
_ ->
{:error,

View file

@ -1,5 +1,6 @@
defmodule AshAuthentication.Strategy.Password do
import AshAuthentication.Dsl
alias __MODULE__.{Dsl, Transformer, Verifier}
use AshAuthentication.Strategy.Custom
@moduledoc """
Strategy for authenticating using local resources as the source of truth.
@ -92,7 +93,7 @@ defmodule AshAuthentication.Strategy.Password do
## DSL Documentation
#{Spark.Dsl.Extension.doc_entity(strategy(:password))}
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
"""
defstruct identity_field: :username,
@ -126,6 +127,10 @@ defmodule AshAuthentication.Strategy.Password do
resource: module
}
defdelegate dsl(), to: Dsl
defdelegate transform(strategy, dsl_state), to: Transformer
defdelegate verify(strategy, dsl_state), to: Verifier
@doc """
Generate a reset token for a user.

View file

@ -0,0 +1,170 @@
defmodule AshAuthentication.Strategy.Password.Dsl do
@moduledoc """
Defines the Spark DSL entity for this strategy.
"""
alias AshAuthentication.Strategy.Password
alias Spark.Dsl.Entity
@default_token_lifetime_days 3
@doc false
@spec dsl :: map
def dsl do
%Entity{
name: :password,
describe: "Strategy for authenticating using local resources as the source of truth.",
examples: [
"""
password :password do
identity_field :email
hashed_password_field :hashed_password
hash_provider AshAuthentication.BcryptProvider
confirmation_required? true
end
"""
],
args: [{:optional, :name, :password}],
hide: [:name],
target: Password,
modules: [:hash_provider],
schema: [
name: [
type: :atom,
doc: """
Uniquely identifies the strategy.
""",
required: true
],
identity_field: [
type: :atom,
doc: """
The name of the attribute which uniquely identifies the user.
Usually something like `username` or `email_address`.
""",
default: :username
],
hashed_password_field: [
type: :atom,
doc: """
The name of the attribute within which to store the user's password
once it has been hashed.
""",
default: :hashed_password
],
hash_provider: [
type: {:behaviour, AshAuthentication.HashProvider},
doc: """
A module which implements the `AshAuthentication.HashProvider`
behaviour.
Used to provide cryptographic hashing of passwords.
""",
default: AshAuthentication.BcryptProvider
],
confirmation_required?: [
type: :boolean,
required: false,
doc: """
Whether a password confirmation field is required when registering or
changing passwords.
""",
default: true
],
password_field: [
type: :atom,
doc: """
The name of the argument used to collect the user's password in
plaintext when registering, checking or changing passwords.
""",
default: :password
],
password_confirmation_field: [
type: :atom,
doc: """
The name of the argument used to confirm the user's password in
plaintext when registering or changing passwords.
""",
default: :password_confirmation
],
register_action_name: [
type: :atom,
doc: """
The name to use for the register action.
If not present it will be generated by prepending the strategy name
with `register_with_`.
""",
required: false
],
sign_in_action_name: [
type: :atom,
doc: """
The name to use for the sign in action.
If not present it will be generated by prependign the strategy name
with `sign_in_with_`.
""",
required: false
]
],
entities: [
resettable: [
%Entity{
name: :resettable,
describe: "Configure password reset options for the resource",
target: Password.Resettable,
schema: [
token_lifetime: [
type: :pos_integer,
doc: """
How long should the reset token be valid, in hours.
Defaults to #{@default_token_lifetime_days} days.
""",
default: @default_token_lifetime_days * 24
],
request_password_reset_action_name: [
type: :atom,
doc: """
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_`.
""",
required: false
],
password_reset_action_name: [
type: :atom,
doc: """
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_`.
""",
required: false
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.Sender,
{AshAuthentication.SenderFunction, 3}},
doc: """
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.
""",
required: true
]
]
}
]
]
}
end
end

View file

@ -3,9 +3,7 @@ defmodule AshAuthentication.Strategy.Password.Resettable do
The entity used to store password reset information.
"""
@default_lifetime_days 3
defstruct token_lifetime: @default_lifetime_days * 24,
defstruct token_lifetime: nil,
request_password_reset_action_name: nil,
password_reset_action_name: nil,
sender: nil
@ -16,61 +14,4 @@ defmodule AshAuthentication.Strategy.Password.Resettable do
password_reset_action_name: atom,
sender: {module, keyword}
}
@doc false
@spec entity :: struct()
def entity do
%Spark.Dsl.Entity{
name: :resettable,
describe: "Configure password reset options for the resource",
target: __MODULE__,
schema: [
token_lifetime: [
type: :pos_integer,
doc: """
How long should the reset token be valid, in hours.
Defaults to #{@default_lifetime_days} days.
""",
default: @default_lifetime_days * 24
],
request_password_reset_action_name: [
type: :atom,
doc: """
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_`.
""",
required: false
],
password_reset_action_name: [
type: :atom,
doc: """
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_`.
""",
required: false
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.Sender,
{AshAuthentication.SenderFunction, 3}},
doc: """
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.
""",
required: true
]
]
}
end
end

View file

@ -18,6 +18,10 @@ defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Password do
"""
@type phase :: :register | :sign_in | :reset_request | :reset
@doc false
@spec name(Password.t()) :: atom
def name(strategy), do: strategy.name
@doc false
@spec phases(Password.t()) :: [phase]
def phases(%{resettable: []}), do: [:register, :sign_in]

View file

@ -6,49 +6,18 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
the correct actions and settings are in place.
"""
use Spark.Dsl.Transformer
alias Ash.{Resource, Type}
alias AshAuthentication.{GenerateTokenChange, Info, Strategy.Password}
alias AshAuthentication.{GenerateTokenChange, Strategy, Strategy.Password}
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Strategy.Custom.Helpers
import AshAuthentication.Utils
import AshAuthentication.Validations
import AshAuthentication.Validations.Action
import AshAuthentication.Validations.Attribute
@doc false
@impl true
@spec after?(module) :: boolean
def after?(AshAuthentication.Transformer), do: true
def after?(_), do: false
@doc false
@impl true
@spec before?(module) :: boolean
def before?(Resource.Transformers.DefaultAccept), do: true
def before?(_), do: false
@doc false
@impl true
@spec transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
dsl_state
|> Info.authentication_strategies()
|> Stream.filter(&is_struct(&1, Password))
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
case transform_strategy(strategy, dsl_state) do
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp transform_strategy(strategy, dsl_state) do
@spec transform(Password.t(), map) :: {:ok, Password.t() | map} | {:error, Exception.t()}
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 <-
@ -78,27 +47,21 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
|> Transformer.replace_entity(
~w[authentication strategies]a,
strategy,
&(&1.name == strategy.name)
&(Strategy.name(&1) == strategy.name)
)
|> then(fn dsl_state ->
~w[sign_in_action_name register_action_name]a
|> Stream.map(&Map.get(strategy, &1))
|> Enum.reduce(
dsl_state,
&Transformer.persist(&2, {:authentication_action, &1}, strategy)
)
|> Enum.map(&Map.get(strategy, &1))
|> register_strategy_actions(dsl_state, strategy)
end)
|> then(fn dsl_state ->
strategy
|> Map.get(:resettable, [])
|> Stream.flat_map(fn resettable ->
|> Enum.flat_map(fn resettable ->
~w[request_password_reset_action_name password_reset_action_name]a
|> Stream.map(&Map.get(resettable, &1))
|> Enum.map(&Map.get(resettable, &1))
end)
|> Enum.reduce(
dsl_state,
&Transformer.persist(&2, {:authentication_action, &1}, strategy)
)
|> register_strategy_actions(dsl_state, strategy)
end)
{:ok, dsl_state}

View file

@ -3,47 +3,13 @@ defmodule AshAuthentication.Strategy.Password.Verifier do
DSL verifier for the password strategy.
"""
use Spark.Dsl.Transformer
alias AshAuthentication.{HashProvider, Info, Sender, Strategy.Password}
alias AshAuthentication.{HashProvider, Sender, Strategy.Password}
alias Spark.Error.DslError
import AshAuthentication.Validations
@doc false
@impl true
@spec after?(module) :: boolean
def after?(_), do: true
@doc false
@impl true
@spec before?(module) :: boolean
def before?(_), do: false
@doc false
@impl true
@spec after_compile? :: boolean
def after_compile?, do: true
@doc false
@impl true
@spec transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
dsl_state
|> Info.authentication_strategies()
|> Stream.filter(&is_struct(&1, Password))
|> Enum.reduce_while(:ok, fn strategy, :ok ->
case transform_strategy(strategy) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
def transform_strategy(strategy) do
@spec verify(Password.t(), map) :: :ok | {:error, Exception.t()}
def verify(strategy, _dsl_state) do
with :ok <- validate_behaviour(strategy.hash_provider, HashProvider) do
maybe_validate_resettable_sender(strategy)
end

View file

@ -36,6 +36,15 @@ defprotocol AshAuthentication.Strategy do
@type http_method ::
:get | :head | :post | :put | :delete | :connect | :options | :trace | :patch
@doc """
The "short name" of the strategy, used for genererating routes, etc.
This is most likely the same value that you use for the entity's `name`
argument.
"""
@spec name(t) :: atom
def name(strategy)
@doc """
Return a list of phases supported by the strategy.

View file

@ -6,6 +6,7 @@ defmodule AshAuthentication.TokenResource.GetConfirmationChangesPreparation do
use Ash.Resource.Preparation
alias Ash.{Query, Resource.Preparation}
alias AshAuthentication.Strategy
require Ash.Query
@doc false
@ -16,7 +17,7 @@ defmodule AshAuthentication.TokenResource.GetConfirmationChangesPreparation do
strategy = query.context.strategy
query
|> Query.filter(purpose: to_string(strategy.name), jti: jti)
|> Query.filter(purpose: to_string(Strategy.name(strategy)), jti: jti)
|> Query.filter(expires_at >= now())
end
end

View file

@ -1,33 +1,22 @@
defmodule AshAuthentication.Verifier do
@moduledoc """
The Authentication verifier.
Checks configuration constraints after compile.
"""
use Spark.Dsl.Transformer
use Spark.Dsl.Verifier
alias AshAuthentication.Info
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Utils
@doc false
@impl true
@spec after?(any) :: boolean
def after?(_), do: true
@doc false
@impl true
@spec before?(any) :: boolean
def before?(_), do: false
@doc false
@impl true
@spec after_compile? :: boolean
def after_compile?, do: true
@doc false
@impl true
@spec transform(map) ::
:ok | {:ok, map} | {:error, term} | {:warn, map, String.t() | [String.t()]} | :halt
def transform(dsl_state) do
@spec verify(map) ::
:ok
| {:error, term}
| {:warn, String.t() | list(String.t())}
def verify(dsl_state) do
with {:ok, _api} <- validate_api_presence(dsl_state) do
validate_token_resource(dsl_state)
end

View file

@ -0,0 +1,47 @@
defmodule AshAuthentication.Strategy.CustomStrategyTest do
@moduledoc false
use DataCase
alias AshAuthentication.{Errors.AuthenticationFailed, Info, Plug.Helpers, Strategy}
import Plug.Test
test "when an existing user whose username doesn't start with \"marty\", they can't sign in" do
strategy = Info.strategy!(Example.User, :marty)
build_user(username: "doc_brown")
conn = conn(:post, "/user/marty", %{"username" => "doc_brown"})
{_conn, {:error, error}} =
strategy
|> Strategy.plug(:sign_in, conn)
|> Helpers.get_authentication_result()
assert %AuthenticationFailed{caused_by: %{reason: :no_user}} = error
end
test "when not an existing user, they can't sign in" do
strategy = Info.strategy!(Example.User, :marty)
conn = conn(:post, "/user/marty", %{"username" => username()})
{_conn, {:error, error}} =
strategy
|> Strategy.plug(:sign_in, conn)
|> Helpers.get_authentication_result()
assert %AuthenticationFailed{caused_by: %{reason: :no_user}} = error
end
test "when an existing user whose username starts with \"marty\", they can sign in" do
strategy = Info.strategy!(Example.User, :marty)
user0 = build_user(username: "marty_mcfly")
conn = conn(:post, "/user/marty", %{"username" => "marty_mcfly"})
{_conn, {:ok, user1}} =
strategy
|> Strategy.plug(:sign_in, conn)
|> Helpers.get_authentication_result()
assert user0.id == user1.id
end
end

View file

@ -1,7 +1,7 @@
defmodule AshAuthentication.Strategy.Password.HashPasswordChangeTest do
use DataCase, async: true
alias Ash.Changeset
alias AshAuthentication.{Info, Strategy.Password.HashPasswordChange}
alias AshAuthentication.{Info, Strategy, Strategy.Password.HashPasswordChange}
describe "change/3" do
test "when the action is associated with a strategy, it can hash the password" do
@ -38,7 +38,7 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChangeTest do
{:ok, _user, _changeset, _} =
Changeset.new(user, %{})
|> Changeset.set_context(%{strategy_name: strategy.name})
|> Changeset.set_context(%{strategy_name: Strategy.name(strategy)})
|> Changeset.for_update(:update, attrs)
|> HashPasswordChange.change([], %{})
|> Changeset.with_hooks(fn changeset ->
@ -61,7 +61,7 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChangeTest do
{:ok, _user, _changeset, _} =
Changeset.new(user, %{})
|> Changeset.for_update(:update, attrs)
|> HashPasswordChange.change([], %{strategy_name: strategy.name})
|> HashPasswordChange.change([], %{strategy_name: Strategy.name(strategy)})
|> Changeset.with_hooks(fn changeset ->
assert strategy.hash_provider.valid?(password, changeset.attributes.hashed_password)

View file

@ -1,7 +1,7 @@
defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidationTest do
use DataCase, async: true
alias Ash.{Changeset, Error.Changes.InvalidArgument}
alias AshAuthentication.{Info, Strategy.Password.PasswordConfirmationValidation}
alias AshAuthentication.{Info, Strategy, Strategy.Password.PasswordConfirmationValidation}
describe "validate/2" do
test "when the action is associated with a strategy, it can validate the password confirmation" do
@ -34,7 +34,7 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidationTest
assert {:error, %InvalidArgument{field: :password_confirmation}} =
Changeset.new(user, %{})
|> Changeset.set_context(%{strategy_name: strategy.name})
|> Changeset.set_context(%{strategy_name: Strategy.name(strategy)})
|> Changeset.for_update(:update, attrs)
|> PasswordConfirmationValidation.validate([])
end

View file

@ -18,7 +18,7 @@ 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}
resettable = %Resettable{password_reset_action_name: :reset, token_lifetime: 72}
strategy = %Password{resettable: [resettable], resource: user.__struct__}
assert {:ok, token} = Password.reset_token_for(strategy, user)

View file

@ -0,0 +1,112 @@
defmodule Example.CustomStrategy do
@moduledoc """
An extremely dumb custom strategy that let's anyone with a name that starts
with "Marty" sign in.
"""
defstruct case_sensitive?: false, name_field: nil, resource: nil
use AshAuthentication.Strategy.Custom
def dsl do
%Spark.Dsl.Entity{
name: :only_marty,
describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.",
examples: [
"""
only_marty do
case_sensitive? true
name_field :name
end
"""
],
target: __MODULE__,
schema: [
case_sensitive?: [
type: :boolean,
doc: """
Ignore letter case when comparing?
""",
required: false,
default: false
],
name_field: [
type: :atom,
doc: """
The field to check for the users' name.
""",
required: true
]
]
}
end
defimpl AshAuthentication.Strategy do
alias AshAuthentication.{Errors.AuthenticationFailed, Info}
require Ash.Query
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
def name(_), do: :marty
def phases(_), do: [:sign_in]
def actions(_), do: [:sign_in]
def routes(strategy) do
subject_name = Info.authentication_subject_name!(strategy.resource)
[
{"/#{subject_name}/marty", :sign_in}
]
end
def method_for_phase(_, :sign_in), do: :post
def plug(strategy, :sign_in, conn) do
params = Map.take(conn.params, [to_string(strategy.name_field)])
result = AshAuthentication.Strategy.action(strategy, :sign_in, params, [])
store_authentication_result(conn, result)
end
def action(strategy, :sign_in, params, _options) do
name_field = strategy.name_field
name = Map.get(params, to_string(name_field))
api = Info.authentication_api!(strategy.resource)
strategy.resource
|> Ash.Query.filter(ref(^name_field) == ^name)
|> Ash.Query.after_action(fn
query, [user] ->
name =
user
|> Map.get(name_field)
|> to_string()
{name, prefix} =
if strategy.case_sensitive? do
{name, "Marty"}
else
{String.downcase(name), "marty"}
end
if String.starts_with?(name, prefix) do
{:ok, [user]}
else
{:error,
AuthenticationFailed.exception(query: query, caused_by: %{reason: :not_a_marty})}
end
query, [] ->
{:error, AuthenticationFailed.exception(query: query, caused_by: %{reason: :no_user})}
query, _ ->
{:error,
AuthenticationFailed.exception(query: query, caused_by: %{reason: :too_many_users})}
end)
|> api.read()
|> case do
{:ok, [user]} -> {:ok, user}
{:error, reason} -> {:error, reason}
end
end
end
end

View file

@ -9,7 +9,9 @@ defmodule Example.GenericOAuth2Change do
def change(changeset, _opts, _context) do
user_info = Changeset.get_argument(changeset, :user_info)
username = user_info["nickname"] || user_info["login"] || user_info["preferred_username"]
changeset
|> Changeset.change_attribute(:username, user_info["nickname"] || user_info["login"])
|> Changeset.change_attribute(:username, username)
end
end

View file

@ -0,0 +1,107 @@
defmodule Example.OnlyMartiesAtTheParty do
@moduledoc """
A really dumb custom strategy that lets anyone named Marty sign in.
"""
defstruct name: :marty, case_sensitive?: false, name_field: nil, resource: nil
use AshAuthentication.Strategy.Custom
def dsl do
%Spark.Dsl.Entity{
name: :only_marty,
describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.",
examples: [
"""
only_marty do
case_sensitive? true
name_field :name
end
"""
],
target: __MODULE__,
args: [{:optional, :name, :marty}],
schema: [
name: [
type: :atom,
doc: """
The strategy name.
""",
required: true
],
case_sensitive?: [
type: :boolean,
doc: """
Ignore letter case when comparing?
""",
required: false,
default: false
],
name_field: [
type: :atom,
doc: """
The field to check for the users' name.
""",
required: true
]
]
}
end
defimpl AshAuthentication.Strategy do
alias AshAuthentication.Errors.AuthenticationFailed
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
require Ash.Query
def name(strategy), do: strategy.name
def phases(_), do: [:sign_in]
def actions(_), do: [:sign_in]
def routes(strategy) do
subject_name = AshAuthentication.Info.authentication_subject_name!(strategy.resource)
[
{"/#{subject_name}/#{strategy.name}", :sign_in}
]
end
def method_for_phase(_, :sign_in), do: :post
def plug(strategy, :sign_in, conn) do
params = Map.take(conn.params, [to_string(strategy.name_field)])
result = action(strategy, :sign_in, params, [])
store_authentication_result(conn, result)
end
def action(strategy, :sign_in, params, options) do
name_field = strategy.name_field
name = Map.get(params, to_string(name_field))
api = AshAuthentication.Info.authentication_api!(strategy.resource)
strategy.resource
|> Ash.Query.filter(ref(^name_field) == ^name)
|> then(fn query ->
if strategy.case_sensitive? do
Ash.Query.filter(query, like(ref(^name_field), "Marty%"))
else
Ash.Query.filter(query, ilike(ref(^name_field), "Marty%"))
end
end)
|> api.read(options)
|> case do
{:ok, [user]} ->
{:ok, user}
{:ok, []} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: :no_user})}
{:ok, _users} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: :too_many_users})}
{:error, reason} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: reason})}
end
end
end
end

View file

@ -191,6 +191,11 @@ defmodule Example.User do
redirect_uri &get_config/2
client_secret &get_config/2
end
only_marty do
case_sensitive?(false)
name_field(:username)
end
end
end