mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 21:03:23 +12:00
feat: Add support and documentation for custom strategies. (#154)
This commit is contained in:
parent
11cf8a8cfa
commit
7e639e4a21
43 changed files with 1845 additions and 958 deletions
|
@ -56,3 +56,5 @@ config :ash_authentication,
|
|||
]
|
||||
|
||||
# config :ash_authentication, debug_authentication_failures?: true
|
||||
|
||||
config :ash_authentication, extra_strategies: [Example.OnlyMartiesAtTheParty]
|
||||
|
|
|
@ -36,3 +36,5 @@ config :ash_authentication,
|
|||
signing_secret: "Marty McFly in the past with the Delorean"
|
||||
]
|
||||
]
|
||||
|
||||
config :ash_authentication, extra_strategies: [Example.OnlyMartiesAtTheParty]
|
||||
|
|
|
@ -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
|
||||
|
|
373
documentation/topics/custom-strategy.md
Normal file
373
documentation/topics/custom-strategy.md
Normal 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!
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
119
lib/ash_authentication/add_ons/confirmation/dsl.ex
Normal file
119
lib/ash_authentication/add_ons/confirmation/dsl.ex
Normal 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
|
|
@ -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]
|
||||
|
|
|
@ -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} <-
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
76
lib/ash_authentication/strategies/auth0.ex
Normal file
76
lib/ash_authentication/strategies/auth0.ex
Normal 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
|
75
lib/ash_authentication/strategies/custom.ex
Normal file
75
lib/ash_authentication/strategies/custom.ex
Normal 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
|
57
lib/ash_authentication/strategies/custom/helpers.ex
Normal file
57
lib/ash_authentication/strategies/custom/helpers.ex
Normal 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
|
122
lib/ash_authentication/strategies/custom/transformer.ex
Normal file
122
lib/ash_authentication/strategies/custom/transformer.ex
Normal 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
|
39
lib/ash_authentication/strategies/custom/verifier.ex
Normal file
39
lib/ash_authentication/strategies/custom/verifier.ex
Normal 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
|
75
lib/ash_authentication/strategies/github.ex
Normal file
75
lib/ash_authentication/strategies/github.ex
Normal 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
|
|
@ -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
|
||||
|
|
292
lib/ash_authentication/strategies/oauth2/dsl.ex
Normal file
292
lib/ash_authentication/strategies/oauth2/dsl.ex
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
170
lib/ash_authentication/strategies/password/dsl.ex
Normal file
170
lib/ash_authentication/strategies/password/dsl.ex
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
47
test/ash_authentication/strategies/custom_strategy_test.exs
Normal file
47
test/ash_authentication/strategies/custom_strategy_test.exs
Normal 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
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
112
test/support/example/custom_strategy.ex
Normal file
112
test/support/example/custom_strategy.ex
Normal 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
|
|
@ -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
|
||||
|
|
107
test/support/example/only_marties_at_the_party.ex
Normal file
107
test/support/example/only_marties_at_the_party.ex
Normal 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
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue