mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-08 05:40:58 +12:00
improvement!: Major redesign of DSL and code structure. (#35)
Highlights: * Replaced `AshAuthentication.Provider` with the much more flexible `AshAuthentication.Strategy`. * Moved strategies to within the `authentication` DSL using entities and removed excess extensions. * Added a lot more documentation and test coverage.
This commit is contained in:
parent
fdba938b70
commit
1c8f138c67
144 changed files with 5217 additions and 4411 deletions
18
LICENSE
Normal file
18
LICENSE
Normal file
|
@ -0,0 +1,18 @@
|
|||
Copyright 2022 Alembic Pty Ltd.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
50
README.md
50
README.md
|
@ -19,17 +19,16 @@ end
|
|||
|
||||
## Usage
|
||||
|
||||
This package assumes that you have [Phoenix](https://phoenixframework.org/) and
|
||||
[Ash](https://ash-hq.org/) installed and configured. See their individual
|
||||
documentation for details.
|
||||
This package assumes that you have [Ash](https://ash-hq.org/) installed and
|
||||
configured. See the Ash documentation for details.
|
||||
|
||||
Once installed you can easily add support for authentication by configuring one
|
||||
or more extensions onto your Ash resource:
|
||||
Once installed you can easily add support for authentication by adding the
|
||||
`AshAuthentication` extension to your resource:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.User do
|
||||
use Ash.Resource,
|
||||
extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication]
|
||||
extensions: [AshAuthentication]
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
@ -39,11 +38,13 @@ defmodule MyApp.Accounts.User do
|
|||
|
||||
authentication do
|
||||
api MyApp.Accounts
|
||||
end
|
||||
|
||||
password_authentication do
|
||||
identity_field :email
|
||||
hashed_password_field :hashed_password
|
||||
strategies do
|
||||
password do
|
||||
identity_field :email
|
||||
hashed_password_field :hashed_password
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
|
@ -55,7 +56,7 @@ end
|
|||
If you plan on providing authentication via the web, then you will need to
|
||||
define a plug using
|
||||
[`AshAuthentication.Plug`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Plug.html)
|
||||
which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) which
|
||||
which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) that
|
||||
routes incoming authentication requests to the correct provider and provides
|
||||
callbacks for you to manipulate the conn after success or failure.
|
||||
|
||||
|
@ -64,18 +65,16 @@ If you're using AshAuthentication with Phoenix, then check out
|
|||
which provides route helpers, a controller abstraction and LiveView components
|
||||
for easy set up.
|
||||
|
||||
## Authentication Providers
|
||||
## Authentication Strategies
|
||||
|
||||
Currently the only supported authentication provider is
|
||||
[`AshAuthentication.PasswordAuthentication`](https://team-alembic.github.io/ash_authentication/AshAuthentication.PasswordAuthentication.html)
|
||||
which provides actions for registering and signing in users using an identifier
|
||||
and a password.
|
||||
Currently supported strategies:
|
||||
|
||||
Planned future providers include:
|
||||
|
||||
* OAuth 1.0
|
||||
* OAuth 2.0
|
||||
* OpenID Connect
|
||||
1. [`AshAuthentication.Strategy.Password`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.Password.html)
|
||||
- authenticate users against your local database using a unique identity
|
||||
(such as username or email address) and a password.
|
||||
2. [`AshAuthentication.Strategy.OAuth2`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.OAuth2.html)
|
||||
- authenticate using local or remote [OAuth 2.0](https://oauth.net/2/)
|
||||
compatible services.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
@ -83,6 +82,10 @@ Documentation for the latest release will be [available on
|
|||
hexdocs](https://hexdocs.pm/ash_authentication) and for the [`main`
|
||||
branch](https://team-alembic.github.io/ash_authentication).
|
||||
|
||||
Additional support can be found on the [GitHub discussions
|
||||
page](https://github.com/team-alembic/ash_authentication/discussions) and the
|
||||
[Ash Discord](https://discord.gg/D7FNG2q).
|
||||
|
||||
## Contributing
|
||||
|
||||
* To contribute updates, fixes or new features please fork and open a
|
||||
|
@ -95,4 +98,7 @@ branch](https://team-alembic.github.io/ash_authentication).
|
|||
|
||||
## Licence
|
||||
|
||||
MIT
|
||||
`AshAuthentication` is licensed under the terms of the [MIT
|
||||
license](https://opensource.org/licenses/MIT). See the [`LICENSE` file in this
|
||||
repository](https://github.com/team-alembic/ash_authentication/blob/main/LICENSE)
|
||||
for details.
|
||||
|
|
|
@ -28,3 +28,18 @@ config :ash_authentication, Example,
|
|||
|
||||
config :ash_authentication, AshAuthentication.Jwt,
|
||||
signing_secret: "Marty McFly in the past with the Delorean"
|
||||
|
||||
config :ash_authentication,
|
||||
authentication: [
|
||||
strategies: [
|
||||
oauth2: [
|
||||
client_id: System.get_env("OAUTH2_CLIENT_ID"),
|
||||
redirect_uri: "http://localhost:4000/auth",
|
||||
client_secret: System.get_env("OAUTH2_CLIENT_SECRET"),
|
||||
site: System.get_env("OAUTH2_SITE"),
|
||||
authorize_path: "/authorize",
|
||||
token_path: "/oauth/token",
|
||||
user_path: "/userinfo"
|
||||
]
|
||||
]
|
||||
]
|
||||
|
|
|
@ -21,3 +21,18 @@ config :ash, :disable_async?, true
|
|||
|
||||
config :ash_authentication, AshAuthentication.Jwt,
|
||||
signing_secret: "Marty McFly in the past with the Delorean"
|
||||
|
||||
config :ash_authentication,
|
||||
authentication: [
|
||||
strategies: [
|
||||
oauth2: [
|
||||
client_id: "pretend client id",
|
||||
redirect_uri: "http://localhost:4000/auth",
|
||||
client_secret: "pretend client secret",
|
||||
site: "https://example.com/",
|
||||
authorize_path: "/authorize",
|
||||
token_path: "/oauth/token",
|
||||
user_path: "/userinfo"
|
||||
]
|
||||
]
|
||||
]
|
||||
|
|
|
@ -6,7 +6,7 @@ defmodule DevServer.ApiRouter do
|
|||
import Example.AuthPlug
|
||||
|
||||
plug(:load_from_bearer)
|
||||
plug(:set_actor, :user_with_username)
|
||||
plug(:set_actor, :user)
|
||||
plug(:match)
|
||||
plug(:dispatch)
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ defmodule DevServer.GqlRouter do
|
|||
import Example.AuthPlug
|
||||
|
||||
plug(:load_from_bearer)
|
||||
plug(:set_actor, :user_with_username)
|
||||
plug(:set_actor, :user)
|
||||
plug(AshGraphql.Plug)
|
||||
plug(:match)
|
||||
plug(:dispatch)
|
||||
|
|
|
@ -4,6 +4,7 @@ defmodule DevServer.TestPage do
|
|||
Überauth providers.
|
||||
"""
|
||||
@behaviour Plug
|
||||
alias AshAuthentication.{Info, Strategy}
|
||||
alias Plug.Conn
|
||||
require EEx
|
||||
|
||||
|
@ -20,7 +21,10 @@ defmodule DevServer.TestPage do
|
|||
@spec call(Conn.t(), any) :: Conn.t()
|
||||
@impl true
|
||||
def call(conn, _opts) do
|
||||
resources = AshAuthentication.authenticated_resources(:ash_authentication)
|
||||
resources =
|
||||
:ash_authentication
|
||||
|> AshAuthentication.authenticated_resources()
|
||||
|> Enum.map(&{&1, Info.authentication_options(&1), Info.authentication_strategies(&1)})
|
||||
|
||||
current_users =
|
||||
conn.assigns
|
||||
|
@ -34,4 +38,157 @@ defmodule DevServer.TestPage do
|
|||
payload = render(resources: resources, current_users: current_users)
|
||||
Conn.send_resp(conn, 200, payload)
|
||||
end
|
||||
|
||||
defp render_strategy(strategy, phase, options)
|
||||
when strategy.provider == :password and phase == :register do
|
||||
EEx.eval_string(
|
||||
~s"""
|
||||
<form method="<%= @method %>" action="<%= @route %>">
|
||||
<fieldset>
|
||||
<legend>Register with <%= @strategy.name %></legend>
|
||||
<input type="text" name="<%= @options.subject_name %>[<%= @strategy.identity_field %>]" placeholder="<%= @strategy.identity_field %>" />
|
||||
<br />
|
||||
<input type="password" name="<%= @options.subject_name %>[<%= @strategy.password_field %>]" placeholder="<%= @strategy.password_field %>" />
|
||||
<br />
|
||||
<%= if @strategy.confirmation_required? do %>
|
||||
<input type="password" name="<%= @options.subject_name %>[<%= @strategy.password_confirmation_field %>]" placeholder="<%= @strategy.password_confirmation_field %>" />
|
||||
<br />
|
||||
<% end %>
|
||||
<input type="submit" value="Register" />
|
||||
</fieldset>
|
||||
</form>
|
||||
""",
|
||||
assigns: [
|
||||
strategy: strategy,
|
||||
route: route_for_phase(strategy, phase),
|
||||
options: options,
|
||||
method: Strategy.method_for_phase(strategy, phase)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
defp render_strategy(strategy, phase, options)
|
||||
when strategy.provider == :password and phase == :sign_in do
|
||||
EEx.eval_string(
|
||||
~s"""
|
||||
<form method="<%= @method %>" action="<%= @route %>">
|
||||
<fieldset>
|
||||
<legend>Sign in with <%= @strategy.name %></legend>
|
||||
<input type="text" name="<%= @options.subject_name %>[<%= @strategy.identity_field %>]" placeholder="<%= @strategy.identity_field %>" />
|
||||
<br />
|
||||
<input type="password" name="<%= @options.subject_name %>[<%= @strategy.password_field %>]" placeholder="<%= @strategy.password_field %>" />
|
||||
<br />
|
||||
<input type="submit" value="Sign in" />
|
||||
</fieldset>
|
||||
</form>
|
||||
""",
|
||||
assigns: [
|
||||
strategy: strategy,
|
||||
route: route_for_phase(strategy, phase),
|
||||
options: options,
|
||||
method: Strategy.method_for_phase(strategy, phase)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
defp render_strategy(strategy, phase, options)
|
||||
when strategy.provider == :password and phase == :reset_request do
|
||||
EEx.eval_string(
|
||||
~s"""
|
||||
<form method="<%= @method %>" action="<%= @route %>">
|
||||
<fieldset>
|
||||
<legend><%= @strategy.name %> reset request</legend>
|
||||
<input type="text" name="<%= @options.subject_name %>[<%= @strategy.identity_field %>]" placeholder="<%= @strategy.identity_field %>" />
|
||||
<br />
|
||||
<input type="submit" value="Request reset" />
|
||||
</fieldset>
|
||||
</form>
|
||||
""",
|
||||
assigns: [
|
||||
strategy: strategy,
|
||||
route: route_for_phase(strategy, phase),
|
||||
options: options,
|
||||
method: Strategy.method_for_phase(strategy, phase)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
defp render_strategy(strategy, phase, options)
|
||||
when strategy.provider == :password and phase == :reset do
|
||||
EEx.eval_string(
|
||||
~s"""
|
||||
<form method="<%= @method %>" action="<%= @route %>">
|
||||
<fieldset>
|
||||
<legend><%= @strategy.name %> reset request</legend>
|
||||
<input type="text" name="reset_token" placeholder="reset_token" />
|
||||
<br />
|
||||
<input type="password" name="<%= @options.subject_name %>[<%= @strategy.password_field %>]" placeholder="<%= @strategy.password_field %>" />
|
||||
<br />
|
||||
<%= if @strategy.confirmation_required? do %>
|
||||
<input type="password" name="<%= @options.subject_name %>[<%= @strategy.password_confirmation_field %>]" placeholder="<%= @strategy.password_confirmation_field %>" />
|
||||
<br />
|
||||
<% end %>
|
||||
<input type="submit" value="Reset" />
|
||||
</fieldset>
|
||||
</form>
|
||||
""",
|
||||
assigns: [
|
||||
strategy: strategy,
|
||||
route: route_for_phase(strategy, phase),
|
||||
options: options,
|
||||
method: Strategy.method_for_phase(strategy, phase)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
defp render_strategy(strategy, phase, options)
|
||||
when strategy.provider == :confirmation and phase == :confirm do
|
||||
EEx.eval_string(
|
||||
~s"""
|
||||
<form method="<%= @method %>" action="<%= @route %>">
|
||||
<fieldset>
|
||||
<legend><%= @strategy.name %></legend>
|
||||
<input type="text" name="confirm" placeholder="confirmation token" />
|
||||
<br />
|
||||
<input type="submit" value="Confirm" />
|
||||
</fieldset>
|
||||
</form>
|
||||
""",
|
||||
assigns: [
|
||||
strategy: strategy,
|
||||
route: route_for_phase(strategy, phase),
|
||||
options: options,
|
||||
method: Strategy.method_for_phase(strategy, phase)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
defp render_strategy(strategy, phase, _options)
|
||||
when strategy.provider == :oauth2 and phase == :request do
|
||||
EEx.eval_string(
|
||||
~s"""
|
||||
<a href="<%= @route %>">Sign in with <%= @strategy.name %></a>
|
||||
""",
|
||||
assigns: [
|
||||
strategy: strategy,
|
||||
route: route_for_phase(strategy, phase)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
defp render_strategy(strategy, :callback, _) when strategy.provider == :oauth2, do: ""
|
||||
|
||||
defp render_strategy(strategy, phase, _options) do
|
||||
inspect({strategy.provider, phase})
|
||||
end
|
||||
|
||||
defp route_for_phase(strategy, phase) do
|
||||
path =
|
||||
strategy
|
||||
|> Strategy.routes()
|
||||
|> Enum.find(&(elem(&1, 1) == phase))
|
||||
|> elem(0)
|
||||
|
||||
Path.join("/auth", path)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -9,12 +9,14 @@
|
|||
<%= if Enum.any?(@resources) do %>
|
||||
<h2>Resources:</h2>
|
||||
|
||||
<%= for config <- @resources do %>
|
||||
<h2><%= inspect(config.subject_name) %> - <%= Ash.Api.Info.short_name(config.api) %> / <%= Ash.Resource.Info.short_name(config.resource) %></h2>
|
||||
<%= for {resource, options, strategies} <- @resources do %>
|
||||
<h2><%= inspect(options.subject_name) %> - <%= Ash.Api.Info.short_name(options.api) %> / <%= Ash.Resource.Info.short_name(resource) %></h2>
|
||||
|
||||
<%= for provider <- config.providers do %>
|
||||
<%= Module.concat(provider, Html).request(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides(config.resource)}") %>
|
||||
<%= Module.concat(provider, Html).callback(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides(config.resource)}/callback") %>
|
||||
|
||||
<%= for strategy <- strategies do %>
|
||||
<%= for phase <- Strategy.phases(strategy) do %>
|
||||
<%= render_strategy(strategy, phase, options) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
|
|
|
@ -1,83 +1,5 @@
|
|||
defmodule AshAuthentication do
|
||||
import AshAuthentication.Utils, only: [to_sentence: 2]
|
||||
|
||||
@dsl [
|
||||
%Spark.Dsl.Section{
|
||||
name: :authentication,
|
||||
describe: "Configure authentication for this resource",
|
||||
schema: [
|
||||
subject_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The subject name is used in generating token claims and in generating
|
||||
authentication routes.
|
||||
|
||||
This needs to be unique system-wide and if not set will be inferred
|
||||
from the resource name (ie `MyApp.Accounts.User` will have a subject
|
||||
name of `user`).
|
||||
"""
|
||||
],
|
||||
api: [
|
||||
type: {:behaviour, Ash.Api},
|
||||
doc: """
|
||||
The name of the Ash API to use to access this resource when
|
||||
registering/authenticating.
|
||||
""",
|
||||
required: true
|
||||
],
|
||||
get_by_subject_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the read action used to retrieve records.
|
||||
|
||||
Used internally by `AshAuthentication.subject_to_resource/2`. If the
|
||||
action doesn't exist, one will be generated for you.
|
||||
""",
|
||||
default: :get_by_subject
|
||||
]
|
||||
]
|
||||
},
|
||||
%Spark.Dsl.Section{
|
||||
name: :tokens,
|
||||
describe: "Configure JWT settings for this resource",
|
||||
schema: [
|
||||
enabled?: [
|
||||
type: :boolean,
|
||||
doc: """
|
||||
Should JWTs be generated by this resource?
|
||||
""",
|
||||
default: false
|
||||
],
|
||||
signing_algorithm: [
|
||||
type: :string,
|
||||
doc: """
|
||||
The algorithm to use for token signing.
|
||||
|
||||
Available signing algorithms are;
|
||||
#{to_sentence(Joken.Signer.algorithms(), final: "and")}.
|
||||
""",
|
||||
default: hd(Joken.Signer.algorithms())
|
||||
],
|
||||
token_lifetime: [
|
||||
type: :pos_integer,
|
||||
doc: """
|
||||
How long a token should be valid, in hours.
|
||||
"""
|
||||
],
|
||||
revocation_resource: [
|
||||
type: {:behaviour, Ash.Resource},
|
||||
doc: """
|
||||
The resource used to store token revocation information.
|
||||
|
||||
If token generation is enabled for this resource, we need a place to
|
||||
store revocation information. This option is the name of an Ash
|
||||
Resource which has the `AshAuthentication.TokenRevocation` extension
|
||||
present.
|
||||
"""
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
import AshAuthentication.Dsl
|
||||
|
||||
@moduledoc """
|
||||
AshAuthentication provides a turn-key authentication solution for folks using
|
||||
|
@ -85,17 +7,16 @@ defmodule AshAuthentication do
|
|||
|
||||
## Usage
|
||||
|
||||
This package assumes that you have [Phoenix](https://phoenixframework.org/) and
|
||||
[Ash](https://ash-hq.org/) installed and configured. See their individual
|
||||
documentation for details.
|
||||
This package assumes that you have [Ash](https://ash-hq.org/) installed and
|
||||
configured. See the Ash documentation for details.
|
||||
|
||||
Once installed you can easily add support for authentication by configuring one
|
||||
or more extensions onto your Ash resource:
|
||||
Once installed you can easily add support for authentication by configuring
|
||||
the `AshAuthentication` extension on your resource:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.User do
|
||||
use Ash.Resource,
|
||||
extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication]
|
||||
extensions: [AshAuthentication]
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
@ -105,11 +26,13 @@ defmodule AshAuthentication do
|
|||
|
||||
authentication do
|
||||
api MyApp.Accounts
|
||||
end
|
||||
|
||||
password_authentication do
|
||||
identity_field :email
|
||||
hashed_password_field :hashed_password
|
||||
strategies do
|
||||
password do
|
||||
identity_field :email
|
||||
hashed_password_field :hashed_password
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
|
@ -121,41 +44,49 @@ defmodule AshAuthentication do
|
|||
If you plan on providing authentication via the web, then you will need to
|
||||
define a plug using
|
||||
[`AshAuthentication.Plug`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Plug.html)
|
||||
which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) which
|
||||
which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) that
|
||||
routes incoming authentication requests to the correct provider and provides
|
||||
callbacks for you to manipulate the conn after success or failure.
|
||||
|
||||
## Authentication Providers
|
||||
If you're using AshAuthentication with Phoenix, then check out
|
||||
[`ash_authentication_phoenix`](https://github.com/team-alembic/ash_authentication_phoenix)
|
||||
which provides route helpers, a controller abstraction and LiveView components
|
||||
for easy set up.
|
||||
|
||||
Currently the only supported authentication provider is
|
||||
[`AshAuthentication.PasswordAuthentication`](https://team-alembic.github.io/ash_authentication/AshAuthentication.PasswordAuthentication.html)
|
||||
which provides actions for registering and signing in users using an identifier
|
||||
and a password.
|
||||
## Authentication Strategies
|
||||
|
||||
Planned future providers include:
|
||||
Currently supported strategies:
|
||||
|
||||
* OAuth 1.0
|
||||
* OAuth 2.0
|
||||
* OpenID Connect
|
||||
1. [`AshAuthentication.Strategy.Password`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.Password.html)
|
||||
- authenticate users against your local database using a unique identity
|
||||
(such as username or email address) and a password.
|
||||
2. [`AshAuthentication.Strategy.OAuth2`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.OAuth2.html)
|
||||
- authenticate using local or remote [OAuth 2.0](https://oauth.net/2/)
|
||||
compatible services.
|
||||
|
||||
## DSL Documentation
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index(@dsl)}
|
||||
#{Spark.Dsl.Extension.doc_index(dsl())}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc(@dsl)}
|
||||
#{Spark.Dsl.Extension.doc(dsl())}
|
||||
|
||||
"""
|
||||
alias Ash.{Api, Query, Resource}
|
||||
alias Ash.{Api, Error.Query.NotFound, Query, Resource}
|
||||
alias AshAuthentication.Info
|
||||
alias Spark.Dsl.Extension
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: @dsl,
|
||||
transformers: [AshAuthentication.Transformer]
|
||||
sections: dsl(),
|
||||
transformers: [
|
||||
AshAuthentication.Transformer,
|
||||
AshAuthentication.Strategy.Password.Transformer,
|
||||
AshAuthentication.Strategy.OAuth2.Transformer,
|
||||
AshAuthentication.Strategy.Confirmation.Transformer
|
||||
]
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
@ -171,42 +102,40 @@ defmodule AshAuthentication do
|
|||
@doc """
|
||||
Find all resources which support authentication for a given OTP application.
|
||||
|
||||
Returns a map where the key is the authentication provider, and the values are
|
||||
lists of api/resource pairs.
|
||||
Returns a list of resource modules.
|
||||
|
||||
## Example
|
||||
|
||||
iex> authenticated_resources(:ash_authentication)
|
||||
[Example.User]
|
||||
|
||||
This is primarily useful for introspection, but also allows us to simplify
|
||||
token lookup.
|
||||
"""
|
||||
@spec authenticated_resources(atom) :: [resource_config]
|
||||
@spec authenticated_resources(atom) :: [Resource.t()]
|
||||
def authenticated_resources(otp_app) do
|
||||
otp_app
|
||||
|> Application.get_env(:ash_apis, [])
|
||||
|> Stream.flat_map(&Api.Info.resources(&1))
|
||||
|> Stream.map(&resource_config/1)
|
||||
|> Stream.reject(&(&1 == :error))
|
||||
|> Stream.filter(&(AshAuthentication in Spark.extensions(&1)))
|
||||
|> Enum.to_list()
|
||||
end
|
||||
|
||||
def resource_config(resource) do
|
||||
resource
|
||||
|> Extension.get_persisted(:authentication)
|
||||
|> case do
|
||||
nil ->
|
||||
:error
|
||||
|
||||
config ->
|
||||
Map.put(config, :resource, resource)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Return a subject string for an AshAuthentication resource.
|
||||
Return a subject string for user.
|
||||
|
||||
This is done by concatenating the resource's subject name with the resource's
|
||||
primary key field(s) to generate a uri-like string.
|
||||
|
||||
Example:
|
||||
|
||||
iex> build_user(id: "ce7969f9-afa5-474c-bc52-ac23a103cef6") |> user_to_subject()
|
||||
"user?id=ce7969f9-afa5-474c-bc52-ac23a103cef6"
|
||||
|
||||
"""
|
||||
@spec resource_to_subject(Resource.record()) :: subject
|
||||
def resource_to_subject(record) do
|
||||
@spec user_to_subject(Resource.record()) :: subject
|
||||
def user_to_subject(record) do
|
||||
subject_name =
|
||||
record.__struct__
|
||||
|> AshAuthentication.Info.authentication_subject_name!()
|
||||
|> Info.authentication_subject_name!()
|
||||
|
||||
record.__struct__
|
||||
|> Resource.Info.primary_key()
|
||||
|
@ -216,30 +145,34 @@ defmodule AshAuthentication do
|
|||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Given a subject string, attempt to retrieve a resource.
|
||||
"""
|
||||
@spec subject_to_resource(subject | URI.t(), resource_config) ::
|
||||
{:ok, Resource.record()} | {:error, any}
|
||||
def subject_to_resource(subject, config) when is_binary(subject),
|
||||
do: subject |> URI.parse() |> subject_to_resource(config)
|
||||
@doc ~S"""
|
||||
Given a subject string, attempt to retrieve a user record.
|
||||
|
||||
def subject_to_resource(%URI{path: subject_name, query: primary_key} = _subject, config)
|
||||
when is_map(config) do
|
||||
with ^subject_name <- to_string(config.subject_name),
|
||||
{:ok, action_name} <- Info.authentication_get_by_subject_action_name(config.resource) do
|
||||
iex> %{id: user_id} = build_user()
|
||||
...> {:ok, %{id: ^user_id}} = subject_to_user("user?id=#{user_id}", Example.User)
|
||||
"""
|
||||
@spec subject_to_user(subject | URI.t(), Resource.t()) ::
|
||||
{:ok, Resource.record()} | {:error, any}
|
||||
def subject_to_user(subject, resource) when is_binary(subject),
|
||||
do: subject |> URI.parse() |> subject_to_user(resource)
|
||||
|
||||
def subject_to_user(%URI{path: subject_name, query: primary_key} = _subject, resource) do
|
||||
with {:ok, resource_subject_name} <- Info.authentication_subject_name(resource),
|
||||
^subject_name <- to_string(resource_subject_name),
|
||||
{:ok, action_name} <- Info.authentication_get_by_subject_action_name(resource),
|
||||
{:ok, api} <- Info.authentication_api(resource) do
|
||||
primary_key =
|
||||
primary_key
|
||||
|> URI.decode_query()
|
||||
|> Enum.to_list()
|
||||
|
||||
config.resource
|
||||
resource
|
||||
|> Query.for_read(action_name, %{})
|
||||
|> Query.filter(^primary_key)
|
||||
|> config.api.read()
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, [user]} -> {:ok, user}
|
||||
_ -> {:error, "Invalid subject"}
|
||||
_ -> {:error, NotFound.exception([])}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,6 +6,12 @@ defmodule AshAuthentication.BcryptProvider do
|
|||
|
||||
@doc """
|
||||
Given some user input as a string, convert it into it's hashed form using `Bcrypt`.
|
||||
|
||||
## Example
|
||||
|
||||
iex> {:ok, hashed} = hash("Marty McFly")
|
||||
...> String.starts_with?(hashed, "$2b$04$")
|
||||
true
|
||||
"""
|
||||
@impl true
|
||||
@spec hash(String.t()) :: {:ok, String.t()} | :error
|
||||
|
@ -14,6 +20,12 @@ defmodule AshAuthentication.BcryptProvider do
|
|||
|
||||
@doc """
|
||||
Check if the user input matches the hash.
|
||||
|
||||
## Example
|
||||
|
||||
iex> valid?("Marty McFly", "$2b$04$qgacrnrAJz8aPwaVQiGJn.PvryldV.NfOSYYvF/CZAGgMvvzhIE7S")
|
||||
true
|
||||
|
||||
"""
|
||||
@impl true
|
||||
@spec valid?(input :: String.t(), hash :: String.t()) :: boolean
|
||||
|
@ -22,6 +34,11 @@ defmodule AshAuthentication.BcryptProvider do
|
|||
|
||||
@doc """
|
||||
Simulate a password check to help avoid timing attacks.
|
||||
|
||||
## Example
|
||||
|
||||
iex> simulate()
|
||||
false
|
||||
"""
|
||||
@impl true
|
||||
@spec simulate :: false
|
||||
|
|
|
@ -1,225 +0,0 @@
|
|||
defmodule AshAuthentication.Confirmation do
|
||||
@default_lifetime_days 3
|
||||
|
||||
@dsl [
|
||||
%Spark.Dsl.Section{
|
||||
name: :confirmation,
|
||||
describe: "User confirmation behaviour",
|
||||
schema: [
|
||||
token_lifetime: [
|
||||
type: :pos_integer,
|
||||
doc: """
|
||||
How long should the confirmation token be valid, in hours.
|
||||
|
||||
Defaults to #{@default_lifetime_days} days.
|
||||
""",
|
||||
default: @default_lifetime_days * 24
|
||||
],
|
||||
monitor_fields: [
|
||||
type: {:list, :atom},
|
||||
doc: """
|
||||
A list of fields to monitor for changes (eg `[:email, :phone_number]`).
|
||||
""",
|
||||
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?
|
||||
""",
|
||||
default: true
|
||||
],
|
||||
confirm_on_update?: [
|
||||
type: :boolean,
|
||||
doc: """
|
||||
Generate and send a confirmation token when a resource is changed?
|
||||
""",
|
||||
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 confirmation token and the changeset updated to not make the requested change. When the token is confirmed, the change will be applied.
|
||||
""",
|
||||
default: false
|
||||
],
|
||||
sender: [
|
||||
type:
|
||||
{:spark_function_behaviour, AshAuthentication.Sender,
|
||||
{AshAuthentication.SenderFunction, 2}},
|
||||
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.
|
||||
|
||||
See `AshAuthentication.Sender` for more information.
|
||||
""",
|
||||
required: true
|
||||
],
|
||||
confirm_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the action to use when performing confirmation.
|
||||
""",
|
||||
default: :confirm
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@moduledoc """
|
||||
Add a confirmation steps to creates and updates.
|
||||
|
||||
This extension provides a mechanism to force users to confirm some of their
|
||||
details upon create as in your typical "email confirmation" flow.
|
||||
|
||||
## Senders
|
||||
|
||||
You can set the DSL's `sender` key to be either a three-arity anonymous
|
||||
function or a module which implements the `AshAuthentication.Sender`
|
||||
behaviour. This callback can be used to send confirmation instructions to the
|
||||
user via the system of your choice. See `AshAuthentication.Sender` for more
|
||||
information.
|
||||
|
||||
## Usage
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.Users do
|
||||
use Ash.Resource, extensions: [AshAuthentication.Confirmation]
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :email, :ci_string, allow_nil?: false
|
||||
end
|
||||
|
||||
confirmation do
|
||||
monitor_fields [:email]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
A confirmation can be sent to either the `request` or `callback` endpoints.
|
||||
The only required parameter is `"confirm"` which should contain the
|
||||
confirmation token.
|
||||
|
||||
## DSL Documentation
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index(@dsl)}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc(@dsl)}
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: @dsl,
|
||||
transformers: [AshAuthentication.Confirmation.Transformer]
|
||||
|
||||
use AshAuthentication.Provider
|
||||
|
||||
alias Ash.{Changeset, Resource}
|
||||
alias AshAuthentication.{Confirmation, Jwt}
|
||||
|
||||
@doc """
|
||||
Generate a confirmation token for the changes in the changeset.
|
||||
|
||||
## Example
|
||||
|
||||
iex> changeset = Ash.Changeset.for_create(MyApp.Accounts.User, :register, %{"email" => "marty@myfly.me", # ... })
|
||||
...> confirmation_token_for(changeset)
|
||||
{:ok, "abc123"}
|
||||
"""
|
||||
@spec confirmation_token_for(Changeset.t(), Resource.record()) ::
|
||||
{:ok, String.t()} | {:error, any}
|
||||
def confirmation_token_for(changeset, user) when changeset.resource == user.__struct__ do
|
||||
resource = changeset.resource
|
||||
|
||||
with true <- enabled?(resource),
|
||||
{:ok, monitored_fields} <- Confirmation.Info.monitor_fields(resource),
|
||||
changes <- get_changes(changeset, monitored_fields),
|
||||
{:ok, action} <- Confirmation.Info.confirm_action_name(resource),
|
||||
{:ok, lifetime} <- Confirmation.Info.token_lifetime(resource),
|
||||
{:ok, token, _claims} <-
|
||||
Jwt.token_for_record(user, %{"act" => action, "chg" => changes},
|
||||
token_lifetime: lifetime
|
||||
) do
|
||||
{:ok, token}
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
_ -> {:error, "Confirmation not supported by resource `#{inspect(resource)}`"}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_changes(changeset, monitored_fields) do
|
||||
monitored_fields
|
||||
|> Enum.filter(&Changeset.changing_attribute?(changeset, &1))
|
||||
|> Enum.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))})
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirm a creation or change.
|
||||
|
||||
## Example
|
||||
|
||||
iex> confirm(MyApp.Accounts.User, %{"confirm" => "abc123"})
|
||||
{:ok, user}
|
||||
"""
|
||||
@spec confirm(Resource.t(), params) :: {:ok, Resource.record()} | {:error, any}
|
||||
when params: %{required(String.t()) => String.t()}
|
||||
def confirm(resource, params) do
|
||||
with true <- enabled?(resource),
|
||||
{:ok, token} <- Map.fetch(params, "confirm"),
|
||||
{:ok, %{"sub" => subject}} <- Jwt.peek(token),
|
||||
config <- AshAuthentication.resource_config(resource),
|
||||
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config),
|
||||
{:ok, action} <- Confirmation.Info.confirm_action_name(resource),
|
||||
{:ok, api} <- AshAuthentication.Info.authentication_api(resource) do
|
||||
user
|
||||
|> Changeset.for_update(action, %{"confirm" => token})
|
||||
|> api.update()
|
||||
else
|
||||
false -> {:error, "Confirmation not supported by resource `#{inspect(resource)}`"}
|
||||
{:ok, _} -> {:error, "Invalid confirmation token"}
|
||||
:error -> {:error, "Invalid confirmation token"}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handle the callback phase.
|
||||
|
||||
Handles confirmation via the same endpoint.
|
||||
"""
|
||||
@impl true
|
||||
defdelegate callback_plug(conn, opts), to: Confirmation.Plug, as: :handle
|
||||
|
||||
@doc """
|
||||
Handle the request phase.
|
||||
|
||||
Handles confirmation via the same endpoint.
|
||||
"""
|
||||
@impl true
|
||||
defdelegate request_plug(conn, opts), to: Confirmation.Plug, as: :handle
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def provides(_), do: "confirm"
|
||||
end
|
|
@ -1,35 +0,0 @@
|
|||
defmodule AshAuthentication.Confirmation.ConfirmChange do
|
||||
@moduledoc """
|
||||
Performs a change based on the contents of a confirmation token.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias AshAuthentication.{Confirmation.Info, Jwt}
|
||||
alias Ash.{Changeset, Error.Changes.InvalidArgument, Resource.Change}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _) do
|
||||
changeset
|
||||
|> Changeset.before_action(fn changeset ->
|
||||
with token when is_binary(token) <- Changeset.get_argument(changeset, :confirm),
|
||||
{:ok, %{"act" => token_action, "chg" => changes}, _} <-
|
||||
Jwt.verify(token, changeset.resource),
|
||||
{:ok, resource_action} <- Info.confirm_action_name(changeset.resource),
|
||||
true <- to_string(resource_action) == token_action,
|
||||
{:ok, allowed_fields} <- Info.monitor_fields(changeset.resource),
|
||||
{:ok, confirmed_at} <- Info.confirmed_at_field(changeset.resource) do
|
||||
allowed_changes =
|
||||
changes
|
||||
|> Map.take(Enum.map(allowed_fields, &to_string/1))
|
||||
|
||||
changeset
|
||||
|> Changeset.change_attributes(allowed_changes)
|
||||
|> Changeset.change_attribute(confirmed_at, DateTime.utc_now())
|
||||
else
|
||||
_ -> {:error, InvalidArgument.exception(field: :confirm, message: "is not valid")}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -1,88 +0,0 @@
|
|||
defmodule AshAuthentication.Confirmation.ConfirmationHookChange do
|
||||
@moduledoc """
|
||||
Triggers a confirmation flow when one of the monitored fields is changed.
|
||||
|
||||
Optionally inhibits changes to monitored fields on update.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias AshAuthentication.{Confirmation, Confirmation.Info}
|
||||
alias Ash.{Changeset, Resource.Change}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
changeset
|
||||
|> Changeset.before_action(fn changeset ->
|
||||
options = Info.options(changeset.resource)
|
||||
|
||||
changeset
|
||||
|> not_confirm_action(options)
|
||||
|> should_confirm_action_type(options)
|
||||
|> monitored_field_changing(options)
|
||||
|> changes_would_be_valid()
|
||||
|> maybe_inhibit_updates(options)
|
||||
|> maybe_perform_confirmation(options, changeset)
|
||||
end)
|
||||
end
|
||||
|
||||
defp not_confirm_action(changeset, options)
|
||||
when changeset.action != options.confirm_action_name,
|
||||
do: changeset
|
||||
|
||||
defp not_confirm_action(_changeset, _options), do: nil
|
||||
|
||||
defp should_confirm_action_type(changeset, options)
|
||||
when changeset.action_type == :create and options.confirm_on_create?,
|
||||
do: changeset
|
||||
|
||||
defp should_confirm_action_type(changeset, options)
|
||||
when changeset.action_type == :update and options.confirm_on_update?,
|
||||
do: changeset
|
||||
|
||||
defp should_confirm_action_type(_changeset, _options), do: nil
|
||||
|
||||
defp monitored_field_changing(nil, _options), do: nil
|
||||
|
||||
defp monitored_field_changing(changeset, options) do
|
||||
if Enum.any?(options.monitor_fields, &Changeset.changing_attribute?(changeset, &1)),
|
||||
do: changeset,
|
||||
else: nil
|
||||
end
|
||||
|
||||
defp changes_would_be_valid(changeset) when changeset.valid?, do: changeset
|
||||
defp changes_would_be_valid(_), do: nil
|
||||
|
||||
defp maybe_inhibit_updates(changeset, options)
|
||||
when changeset.action_type == :update and options.inhibit_updates? do
|
||||
options.monitor_fields
|
||||
|> Enum.reduce(changeset, &Changeset.clear_change(&2, &1))
|
||||
end
|
||||
|
||||
defp maybe_inhibit_updates(changeset, _options), do: changeset
|
||||
|
||||
defp maybe_perform_confirmation(nil, _options, original_changeset), do: original_changeset
|
||||
|
||||
defp maybe_perform_confirmation(changeset, options, original_changeset) do
|
||||
changeset
|
||||
|> Changeset.after_action(fn _changeset, user ->
|
||||
original_changeset
|
||||
|> Confirmation.confirmation_token_for(user)
|
||||
|> case do
|
||||
{:ok, token} ->
|
||||
{sender, send_opts} = options.sender
|
||||
sender.send(user, token, send_opts)
|
||||
|
||||
metadata =
|
||||
user.__metadata__
|
||||
|> Map.put(:confirmation_token, token)
|
||||
|
||||
{:ok, %{user | __metadata__: metadata}}
|
||||
|
||||
_ ->
|
||||
{:ok, user}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -1,65 +0,0 @@
|
|||
defmodule AshAuthentication.Confirmation.Html do
|
||||
@moduledoc """
|
||||
Renders a very basic form for handling a confirmation token.
|
||||
|
||||
These are mainly used for testing, and you should instead write your own or
|
||||
use the widgets in `ash_authentication_phoenix`.
|
||||
"""
|
||||
|
||||
require EEx
|
||||
alias AshAuthentication.Confirmation
|
||||
|
||||
EEx.function_from_string(
|
||||
:defp,
|
||||
:render,
|
||||
~s"""
|
||||
<form method="<%= @method %>" action="<%= @action %>">
|
||||
<fieldset>
|
||||
<%= if @legend do %><legend><%= @legend %></legend><% end %>
|
||||
<input type="text" name="confirm" placeholder="Confirmation token" />
|
||||
<br />
|
||||
<input type="submit" value="Confirm" />
|
||||
</fieldset>
|
||||
</form>
|
||||
""",
|
||||
[:assigns]
|
||||
)
|
||||
|
||||
@defaults [method: "POST", legend: "Confirm"]
|
||||
|
||||
@type options :: [method_option | action_option]
|
||||
|
||||
@typedoc """
|
||||
The HTTP method used to submit the form.
|
||||
|
||||
Defaults to `#{inspect(Keyword.get(@defaults, :method))}`.
|
||||
"""
|
||||
@type method_option :: {:method, String.t()}
|
||||
|
||||
@typedoc """
|
||||
The path/URL to which the form should be submitted.
|
||||
"""
|
||||
@type action_option :: {:action, String.t()}
|
||||
|
||||
@doc false
|
||||
@spec callback(module, options) :: String.t()
|
||||
def callback(_module, _options), do: ""
|
||||
|
||||
@doc """
|
||||
Render a basic HTML confirmation form.
|
||||
"""
|
||||
@spec request(module, options) :: String.t()
|
||||
def request(resource, options) do
|
||||
resource
|
||||
|> build_assigns(options)
|
||||
|> render()
|
||||
end
|
||||
|
||||
defp build_assigns(resource, options) do
|
||||
@defaults
|
||||
|> Keyword.merge(options)
|
||||
|> Map.new()
|
||||
|> Map.merge(Confirmation.Info.options(resource))
|
||||
|> Map.merge(AshAuthentication.Info.authentication_options(resource))
|
||||
end
|
||||
end
|
|
@ -1,9 +0,0 @@
|
|||
defmodule AshAuthentication.Confirmation.Info do
|
||||
@moduledoc """
|
||||
Generated configuration functions based on a resource's DSL configuration.
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication.Confirmation,
|
||||
sections: [:confirmation]
|
||||
end
|
|
@ -1,23 +0,0 @@
|
|||
defmodule AshAuthentication.Confirmation.Plug do
|
||||
@moduledoc """
|
||||
Handlers for incoming HTTP requests.
|
||||
"""
|
||||
|
||||
import AshAuthentication.Plug.Helpers, only: [private_store: 2]
|
||||
alias AshAuthentication.Confirmation
|
||||
alias Plug.Conn
|
||||
|
||||
@doc """
|
||||
Handle an inbound confirmation request.
|
||||
"""
|
||||
@spec handle(Conn.t(), any) :: Conn.t()
|
||||
def handle(%{params: params, private: %{authenticator: config}} = conn, _opts) do
|
||||
case Confirmation.confirm(config.resource, params) do
|
||||
{:ok, user} ->
|
||||
private_store(conn, {:success, user})
|
||||
|
||||
{:error, reason} ->
|
||||
private_store(conn, {:failure, reason})
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,222 +0,0 @@
|
|||
defmodule AshAuthentication.Confirmation.Transformer do
|
||||
@moduledoc """
|
||||
The Confirmation transformer.
|
||||
|
||||
Scans the resource and checks that all the fields and actions needed are present.
|
||||
"""
|
||||
use Spark.Dsl.Transformer
|
||||
|
||||
alias AshAuthentication.Confirmation.{
|
||||
ConfirmationHookChange,
|
||||
ConfirmChange,
|
||||
Info
|
||||
}
|
||||
|
||||
alias Ash.{Resource, Type}
|
||||
alias AshAuthentication.{GenerateTokenChange, Sender}
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
import AshAuthentication.Validations.Action
|
||||
import AshAuthentication.Validations.Attribute
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec transform(map) ::
|
||||
:ok
|
||||
| {:ok, map()}
|
||||
| {:error, term()}
|
||||
| {:warn, map(), String.t() | [String.t()]}
|
||||
| :halt
|
||||
def transform(dsl_state) do
|
||||
with :ok <- validate_extension(dsl_state, AshAuthentication),
|
||||
:ok <- validate_token_generation_enabled(dsl_state),
|
||||
{:ok, {sender, _opts}} <- Info.sender(dsl_state),
|
||||
:ok <- validate_behaviour(sender, Sender),
|
||||
:ok <- validate_monitor_fields(dsl_state),
|
||||
{:ok, action_name} <- Info.confirm_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(dsl_state, action_name, &build_confirm_action(&1, action_name)),
|
||||
:ok <- validate_confirm_action(dsl_state, action_name),
|
||||
{:ok, confirmed_at} <- Info.confirmed_at_field(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(
|
||||
dsl_state,
|
||||
confirmed_at,
|
||||
&build_confirmed_at_attribute(&1, confirmed_at)
|
||||
),
|
||||
:ok <- validate_confirmed_at_attribute(dsl_state),
|
||||
{:ok, dsl_state} <- maybe_build_change(dsl_state, ConfirmationHookChange) do
|
||||
authentication =
|
||||
Transformer.get_persisted(dsl_state, :authentication)
|
||||
|> Map.update(
|
||||
:providers,
|
||||
[AshAuthentication.Confirmation],
|
||||
&[AshAuthentication.Confirmation | &1]
|
||||
)
|
||||
|
||||
dsl_state =
|
||||
dsl_state
|
||||
|> Transformer.persist(:authentication, authentication)
|
||||
|
||||
{:ok, dsl_state}
|
||||
else
|
||||
:error -> {:error, "Configuration error"}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@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
|
||||
|
||||
defp validate_confirmed_at_attribute(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, field_name} <- Info.confirmed_at_field(dsl_state),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, field_name),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :type, [Type.UtcDatetimeUsec]) do
|
||||
:ok
|
||||
else
|
||||
:error ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:confirmation],
|
||||
message: "The `confirmed_at_field` option must be set."
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_monitor_fields(dsl_state) do
|
||||
case Info.monitor_fields(dsl_state) do
|
||||
{:ok, [_ | _] = fields} ->
|
||||
Enum.reduce_while(fields, :ok, &validate_monitored_field_reducer(dsl_state, &1, &2))
|
||||
|
||||
_ ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:confirmation],
|
||||
message:
|
||||
"The `AshAuthentication.Confirmation` extension requires at least one monitored field to be configured."
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_monitored_field_reducer(dsl_state, field, _) do
|
||||
case validate_monitored_field(dsl_state, field) do
|
||||
:ok -> {:cont, :ok}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_monitored_field(dsl_state, field) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, field),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]) do
|
||||
maybe_validate_eager_checking(dsl_state, field, resource)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_validate_eager_checking(dsl_state, field, resource) do
|
||||
if Info.inhibit_updates?(dsl_state) do
|
||||
dsl_state
|
||||
|> Resource.Info.identities()
|
||||
|> Enum.find(&(&1.keys == [field]))
|
||||
|> case do
|
||||
%{eager_check_with: nil} ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:identities, :identity],
|
||||
message:
|
||||
"The attribute `#{inspect(field)}` on the resource `#{inspect(resource)}` needs the `eager_check_with` property set so that inhibited changes are still validated."
|
||||
)}
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp build_confirm_action(dsl_state, action_name) do
|
||||
with {:ok, fields} <- Info.monitor_fields(dsl_state) do
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :argument,
|
||||
name: :confirm,
|
||||
type: Type.String,
|
||||
allow_nil?: false
|
||||
)
|
||||
]
|
||||
|
||||
changes = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
|
||||
change: ConfirmChange
|
||||
),
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
|
||||
change: GenerateTokenChange
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :update,
|
||||
name: action_name,
|
||||
accept: fields,
|
||||
arguments: arguments,
|
||||
changes: changes
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_build_attribute(dsl_state, attribute_name, builder) do
|
||||
with {:error, _} <- find_attribute(dsl_state, attribute_name),
|
||||
{:ok, attribute} <- builder.(dsl_state) do
|
||||
{:ok, Transformer.add_entity(dsl_state, [:attributes], attribute)}
|
||||
else
|
||||
{:ok, attribute} when is_struct(attribute, Resource.Attribute) -> {:ok, dsl_state}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_confirmed_at_attribute(_dsl_state, attribute_name) do
|
||||
Transformer.build_entity(Resource.Dsl, [:attributes], :attribute,
|
||||
name: attribute_name,
|
||||
type: Type.UtcDatetimeUsec,
|
||||
allow_nil?: true,
|
||||
writable?: true
|
||||
)
|
||||
end
|
||||
|
||||
defp maybe_build_change(dsl_state, change_module) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
changes <- Resource.Info.changes(resource),
|
||||
false <- change_module in changes,
|
||||
{:ok, change} <-
|
||||
Transformer.build_entity(Resource.Dsl, [:changes], :change, change: change_module) do
|
||||
{:ok, Transformer.add_entity(dsl_state, [:changes], change)}
|
||||
else
|
||||
true -> {:ok, dsl_state}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_confirm_action(dsl_state, action_name) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
:ok <- validate_action_has_change(action, ConfirmChange),
|
||||
:ok <- validate_action_argument_option(action, :confirm, :type, [Type.String]) do
|
||||
validate_action_argument_option(action, :confirm, :allow_nil?, [false])
|
||||
end
|
||||
end
|
||||
end
|
532
lib/ash_authentication/dsl.ex
Normal file
532
lib/ash_authentication/dsl.ex
Normal file
|
@ -0,0 +1,532 @@
|
|||
defmodule AshAuthentication.Dsl do
|
||||
@moduledoc false
|
||||
|
||||
###
|
||||
### Only exists to move the DSL out of `AshAuthentication` to aid readability.
|
||||
###
|
||||
|
||||
import AshAuthentication.Utils, only: [to_sentence: 2]
|
||||
import Joken.Signer, only: [algorithms: 0]
|
||||
|
||||
alias Ash.{Api, Resource}
|
||||
|
||||
alias AshAuthentication.{
|
||||
Strategy.Confirmation,
|
||||
Strategy.OAuth2,
|
||||
Strategy.Password
|
||||
}
|
||||
|
||||
alias Spark.{
|
||||
Dsl.Entity,
|
||||
Dsl.Section,
|
||||
OptionsHelpers
|
||||
}
|
||||
|
||||
@shared_strategy_options [
|
||||
name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
Uniquely identifies the strategy.
|
||||
""",
|
||||
required: true
|
||||
]
|
||||
]
|
||||
|
||||
@default_lifetime_days 14
|
||||
|
||||
@secret_type {:or,
|
||||
[
|
||||
{:spark_function_behaviour, AshAuthentication.Secret,
|
||||
{AshAuthentication.SecretFunction, 2}},
|
||||
:string
|
||||
]}
|
||||
|
||||
@secret_doc """
|
||||
Takes either a module which implements the `AshAuthentication.Secret`
|
||||
behaviour, a 2 arity anonymous function or a string.
|
||||
|
||||
See the module documentation for `AshAuthentication.Secret` for more
|
||||
information.
|
||||
"""
|
||||
|
||||
@doc false
|
||||
@spec dsl :: [Section.t()]
|
||||
def dsl do
|
||||
[
|
||||
%Section{
|
||||
name: :authentication,
|
||||
describe: "Configure authentication for this resource",
|
||||
schema: [
|
||||
subject_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The subject name is used anywhere that a short version of your
|
||||
resource name is needed, eg:
|
||||
|
||||
- generating token claims,
|
||||
- generating routes,
|
||||
- form parameter nesting.
|
||||
|
||||
This needs to be unique system-wide and if not set will be inferred
|
||||
from the resource name (ie `MyApp.Accounts.User` will have a subject
|
||||
name of `user`).
|
||||
"""
|
||||
],
|
||||
api: [
|
||||
type: {:behaviour, Api},
|
||||
doc: """
|
||||
The name of the Ash API to use to access this resource when
|
||||
doing anything authenticaiton related.
|
||||
""",
|
||||
required: true
|
||||
],
|
||||
get_by_subject_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the read action used to retrieve records.
|
||||
|
||||
Used internally by `AshAuthentication.subject_to_user/2`. If the
|
||||
action doesn't exist, one will be generated for you.
|
||||
""",
|
||||
default: :get_by_subject
|
||||
]
|
||||
],
|
||||
sections: [
|
||||
%Section{
|
||||
name: :tokens,
|
||||
describe: "Configure JWT settings for this resource",
|
||||
schema: [
|
||||
enabled?: [
|
||||
type: :boolean,
|
||||
doc: """
|
||||
Should JWTs be generated by this resource?
|
||||
""",
|
||||
default: false
|
||||
],
|
||||
signing_algorithm: [
|
||||
type: :string,
|
||||
doc: """
|
||||
The algorithm to use for token signing.
|
||||
|
||||
Available signing algorithms are;
|
||||
#{to_sentence(algorithms(), final: "and")}.
|
||||
""",
|
||||
default: hd(algorithms())
|
||||
],
|
||||
token_lifetime: [
|
||||
type: :pos_integer,
|
||||
doc: """
|
||||
How long a token should be valid, in hours.
|
||||
|
||||
Since refresh tokens are not yet supported, you should
|
||||
probably set this to a reasonably long time to ensure
|
||||
a good user experience.
|
||||
|
||||
Defaults to #{@default_lifetime_days} days.
|
||||
""",
|
||||
default: @default_lifetime_days * 24
|
||||
],
|
||||
revocation_resource: [
|
||||
type: {:behaviour, Resource},
|
||||
doc: """
|
||||
The resource used to store token revocation information.
|
||||
|
||||
If token generation is enabled for this resource, we need a place to
|
||||
store revocation information. This option is the name of an Ash
|
||||
Resource which has the `AshAuthentication.TokenRevocation` extension
|
||||
present.
|
||||
"""
|
||||
]
|
||||
]
|
||||
},
|
||||
%Section{
|
||||
name: :strategies,
|
||||
describe: "Configure authentication strategies on this resource",
|
||||
entities: [
|
||||
strategy(:password),
|
||||
strategy(:oauth2),
|
||||
strategy(:confirmation)
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def strategy(:password) 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: [:name],
|
||||
hide: [:name],
|
||||
target: Password,
|
||||
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: [:name],
|
||||
target: OAuth2,
|
||||
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_path: [
|
||||
type: @secret_type,
|
||||
doc: """
|
||||
The API path to the OAuth2 authorize endpoint.
|
||||
|
||||
Relative to the value of `site`.
|
||||
If not set, it defaults to `#{inspect(OAuth2.Default.default(:authorize_path))}`.
|
||||
|
||||
#{@secret_doc}
|
||||
|
||||
Example:
|
||||
|
||||
```elixir
|
||||
authorize_path fn _, _ -> {:ok, "/authorize"} end
|
||||
```
|
||||
""",
|
||||
required: false
|
||||
],
|
||||
token_path: [
|
||||
type: @secret_type,
|
||||
doc: """
|
||||
The API path to access the token endpoint.
|
||||
|
||||
Relative to the value of `site`.
|
||||
If not set, it defaults to `#{inspect(OAuth2.Default.default(:token_path))}`.
|
||||
|
||||
#{@secret_doc}
|
||||
|
||||
Example:
|
||||
|
||||
```elixir
|
||||
token_path fn _, _ -> {:ok, "/oauth_token"} end
|
||||
```
|
||||
""",
|
||||
required: false
|
||||
],
|
||||
user_path: [
|
||||
type: @secret_type,
|
||||
doc: """
|
||||
The API path to access the user endpoint.
|
||||
|
||||
Relative to the value of `site`.
|
||||
If not set, it defaults to `#{inspect(OAuth2.Default.default(:user_path))}`.
|
||||
|
||||
#{@secret_doc}
|
||||
|
||||
Example:
|
||||
|
||||
```elixir
|
||||
user_path fn _, _ -> {:ok, "/userinfo"} end
|
||||
```
|
||||
""",
|
||||
required: false
|
||||
],
|
||||
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"
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
def strategy(:confirmation) do
|
||||
%Entity{
|
||||
name: :confirmation,
|
||||
describe: "User confirmation flow",
|
||||
target: Confirmation,
|
||||
schema: Confirmation.schema()
|
||||
}
|
||||
end
|
||||
end
|
|
@ -5,6 +5,8 @@ defmodule AshAuthentication.Errors.AuthenticationFailed do
|
|||
use Ash.Error.Exception
|
||||
def_ash_error([], class: :forbidden)
|
||||
|
||||
@type t :: Exception.t()
|
||||
|
||||
defimpl Ash.ErrorKind do
|
||||
@moduledoc false
|
||||
def id(_), do: Ecto.UUID.generate()
|
||||
|
|
15
lib/ash_authentication/errors/invalid_token.ex
Normal file
15
lib/ash_authentication/errors/invalid_token.ex
Normal file
|
@ -0,0 +1,15 @@
|
|||
defmodule AshAuthentication.Errors.InvalidToken do
|
||||
@moduledoc """
|
||||
An invalid token was presented.
|
||||
"""
|
||||
use Ash.Error.Exception
|
||||
def_ash_error([:type], class: :forbidden)
|
||||
|
||||
defimpl Ash.ErrorKind do
|
||||
@moduledoc false
|
||||
def id(_), do: Ecto.UUID.generate()
|
||||
def code(_), do: "invalid_token"
|
||||
def message(%{type: nil}), do: "Invalid token"
|
||||
def message(%{type: type}), do: "Invalid #{type} token"
|
||||
end
|
||||
end
|
17
lib/ash_authentication/errors/missing_secret.ex
Normal file
17
lib/ash_authentication/errors/missing_secret.ex
Normal file
|
@ -0,0 +1,17 @@
|
|||
defmodule AshAuthentication.Errors.MissingSecret do
|
||||
@moduledoc """
|
||||
A secret is now missing.
|
||||
"""
|
||||
use Ash.Error.Exception
|
||||
def_ash_error([:resource], class: :forbidden)
|
||||
|
||||
defimpl Ash.ErrorKind do
|
||||
@moduledoc false
|
||||
def id(_), do: Ecto.UUID.generate()
|
||||
def code(_), do: "missing_secret"
|
||||
|
||||
def message(%{path: path, resource: resource}),
|
||||
do:
|
||||
"Secret for `#{Enum.join(path, ".")}` on the `#{inspect(resource)}` resource is not accessible."
|
||||
end
|
||||
end
|
|
@ -13,8 +13,8 @@ defmodule AshAuthentication.GenerateTokenChange do
|
|||
def change(changeset, _opts, _) do
|
||||
changeset
|
||||
|> Changeset.after_action(fn _changeset, result ->
|
||||
if Info.tokens_enabled?(result.__struct__) do
|
||||
{:ok, token, _claims} = Jwt.token_for_record(result)
|
||||
if Info.authentication_tokens_enabled?(result.__struct__) do
|
||||
{:ok, token, _claims} = Jwt.token_for_user(result)
|
||||
{:ok, %{result | __metadata__: Map.put(result.__metadata__, :token, token)}}
|
||||
else
|
||||
{:ok, result}
|
||||
|
|
|
@ -5,6 +5,33 @@ defmodule AshAuthentication.Info do
|
|||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication,
|
||||
sections: [:authentication, :tokens],
|
||||
prefix?: true
|
||||
sections: [:authentication]
|
||||
|
||||
@doc """
|
||||
Retrieve a named strategy from a resource.
|
||||
"""
|
||||
@spec strategy(dsl_or_resource :: map | module, atom) :: {:ok, strategy} | :error
|
||||
when strategy: struct
|
||||
def strategy(dsl_or_resource, name) do
|
||||
dsl_or_resource
|
||||
|> authentication_strategies()
|
||||
|> Enum.find_value(:error, fn strategy ->
|
||||
if strategy.name == name, do: {:ok, strategy}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Retrieve a named strategy from a resource (raising version).
|
||||
"""
|
||||
@spec strategy!(dsl_or_resource :: map | module, atom) :: strategy | no_return
|
||||
when strategy: struct
|
||||
def strategy!(dsl_or_resource, name) do
|
||||
case strategy(dsl_or_resource, name) do
|
||||
{:ok, strategy} ->
|
||||
strategy
|
||||
|
||||
:error ->
|
||||
raise "No strategy named `#{inspect(name)}` found on resource `#{inspect(dsl_or_resource)}`"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,14 +12,13 @@ defmodule AshAuthentication.InfoGenerator do
|
|||
```
|
||||
"""
|
||||
|
||||
@type options :: [{:extension, module} | {:sections, [atom]} | {:prefix?, boolean}]
|
||||
@type options :: [{:extension, module} | {:sections, [atom]}]
|
||||
|
||||
@doc false
|
||||
@spec __using__(options) :: Macro.t()
|
||||
defmacro __using__(opts) do
|
||||
extension = Keyword.fetch!(opts, :extension) |> Macro.expand(__CALLER__)
|
||||
sections = Keyword.get(opts, :sections, [])
|
||||
prefix? = Keyword.get(opts, :prefix?, false)
|
||||
|
||||
quote do
|
||||
require AshAuthentication.InfoGenerator
|
||||
|
@ -27,14 +26,17 @@ defmodule AshAuthentication.InfoGenerator do
|
|||
|
||||
AshAuthentication.InfoGenerator.generate_config_functions(
|
||||
unquote(extension),
|
||||
unquote(sections),
|
||||
unquote(prefix?)
|
||||
unquote(sections)
|
||||
)
|
||||
|
||||
AshAuthentication.InfoGenerator.generate_options_functions(
|
||||
unquote(extension),
|
||||
unquote(sections),
|
||||
unquote(prefix?)
|
||||
unquote(sections)
|
||||
)
|
||||
|
||||
AshAuthentication.InfoGenerator.generate_entity_functions(
|
||||
unquote(extension),
|
||||
unquote(sections)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
@ -44,17 +46,14 @@ defmodule AshAuthentication.InfoGenerator do
|
|||
which returns a map of all configured options for a resource (including
|
||||
defaults).
|
||||
"""
|
||||
@spec generate_options_functions(module, [atom], boolean) :: Macro.t()
|
||||
defmacro generate_options_functions(_extension, sections, false) when length(sections) > 1,
|
||||
do: raise("Cannot generate options functions for more than one section without prefixes.")
|
||||
|
||||
defmacro generate_options_functions(extension, sections, prefix?) do
|
||||
for {section, options} <- extension_sections_to_list(extension, sections) do
|
||||
function_name = if prefix?, do: :"#{section}_options", else: :options
|
||||
@spec generate_options_functions(module, [atom]) :: Macro.t()
|
||||
defmacro generate_options_functions(extension, sections) do
|
||||
for {path, options} <- extension_sections_to_option_list(extension, sections) do
|
||||
function_name = :"#{Enum.join(path, "_")}_options"
|
||||
|
||||
quote location: :keep do
|
||||
@doc """
|
||||
#{unquote(section)} DSL options
|
||||
#{unquote(Enum.join(path, "."))} DSL options
|
||||
|
||||
Returns a map containing the and any configured or default values.
|
||||
"""
|
||||
|
@ -66,7 +65,7 @@ defmodule AshAuthentication.InfoGenerator do
|
|||
|> Stream.map(fn option ->
|
||||
value =
|
||||
dsl_or_resource
|
||||
|> get_opt([option.section], option.name, Map.get(option, :default))
|
||||
|> get_opt(option.path, option.name, Map.get(option, :default))
|
||||
|
||||
{option.name, value}
|
||||
end)
|
||||
|
@ -78,25 +77,66 @@ defmodule AshAuthentication.InfoGenerator do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Given an extension and a list of DSL sections generate individual config
|
||||
functions for each option.
|
||||
Given an extension and a list of DSL sections, generate an entities function
|
||||
which returns a list of entities.
|
||||
"""
|
||||
@spec generate_config_functions(module, [atom], boolean) :: Macro.t()
|
||||
defmacro generate_config_functions(extension, sections, prefix?) do
|
||||
for {_, options} <- extension_sections_to_list(extension, sections) do
|
||||
for option <- options do
|
||||
function_name = if prefix?, do: :"#{option.section}_#{option.name}", else: option.name
|
||||
@spec generate_entity_functions(module, [atom]) :: Macro.t()
|
||||
defmacro generate_entity_functions(extension, sections) do
|
||||
entity_paths =
|
||||
extension.sections()
|
||||
|> Stream.filter(&(&1.name in sections))
|
||||
|> Stream.flat_map(&explode_section([], &1))
|
||||
|> Stream.filter(fn {_, section} -> Enum.any?(section.entities) end)
|
||||
|> Stream.map(&elem(&1, 0))
|
||||
|
||||
option
|
||||
|> Map.put(:function_name, function_name)
|
||||
|> generate_config_function()
|
||||
for path <- entity_paths do
|
||||
function_name = path |> Enum.join("_") |> String.to_atom()
|
||||
|
||||
quote location: :keep do
|
||||
@doc """
|
||||
#{unquote(Enum.join(path, "."))} DSL entities
|
||||
"""
|
||||
@spec unquote(function_name)(dsl_or_resource :: module | map) :: [struct]
|
||||
def unquote(function_name)(dsl_or_resource) do
|
||||
import Spark.Dsl.Extension, only: [get_entities: 2]
|
||||
|
||||
get_entities(dsl_or_resource, unquote(path))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp extension_sections_to_list(extension, sections) do
|
||||
@doc """
|
||||
Given an extension and a list of DSL sections generate individual config
|
||||
functions for each option.
|
||||
"""
|
||||
@spec generate_config_functions(module, [atom]) :: Macro.t()
|
||||
defmacro generate_config_functions(extension, sections) do
|
||||
for {_, options} <- extension_sections_to_option_list(extension, sections) do
|
||||
for option <- options do
|
||||
generate_config_function(option)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp explode_section(path, %{sections: [], name: name} = section),
|
||||
do: [{path ++ [name], section}]
|
||||
|
||||
defp explode_section(path, %{sections: sections, name: name} = section) do
|
||||
path = path ++ [name]
|
||||
|
||||
head = [{path, section}]
|
||||
tail = Stream.flat_map(sections, &explode_section(path, &1))
|
||||
|
||||
Stream.concat(head, tail)
|
||||
end
|
||||
|
||||
defp extension_sections_to_option_list(extension, sections) do
|
||||
extension.sections()
|
||||
|> Stream.map(fn section ->
|
||||
|> Stream.filter(&(&1.name in sections))
|
||||
|> Stream.flat_map(&explode_section([], &1))
|
||||
|> Stream.reject(fn {_, section} -> Enum.empty?(section.schema) end)
|
||||
|> Stream.map(fn {path, section} ->
|
||||
schema =
|
||||
section.schema
|
||||
|> Enum.map(fn {name, opts} ->
|
||||
|
@ -106,26 +146,35 @@ defmodule AshAuthentication.InfoGenerator do
|
|||
|> Map.update!(:type, &spec_for_type/1)
|
||||
|> Map.put(:pred?, name |> to_string() |> String.ends_with?("?"))
|
||||
|> Map.put(:name, name)
|
||||
|> Map.put(:section, section.name)
|
||||
|> Map.put(:path, path)
|
||||
|> Map.put(
|
||||
:function_name,
|
||||
path
|
||||
|> Enum.concat([name])
|
||||
|> Enum.join("_")
|
||||
|> String.trim_trailing("?")
|
||||
|> String.to_atom()
|
||||
)
|
||||
end)
|
||||
|
||||
{section.name, schema}
|
||||
{path, schema}
|
||||
end)
|
||||
|> Map.new()
|
||||
|> Map.take(sections)
|
||||
end
|
||||
|
||||
defp generate_config_function(%{pred?: true} = option) do
|
||||
function_name = :"#{option.function_name}?"
|
||||
|
||||
quote location: :keep do
|
||||
@doc unquote(option.doc)
|
||||
@spec unquote(option.function_name)(dsl_or_resource :: module | map) ::
|
||||
@spec unquote(function_name)(dsl_or_resource :: module | map) ::
|
||||
unquote(option.type)
|
||||
def unquote(option.function_name)(dsl_or_resource) do
|
||||
def unquote(function_name)(dsl_or_resource) do
|
||||
import Spark.Dsl.Extension, only: [get_opt: 4]
|
||||
|
||||
get_opt(
|
||||
dsl_or_resource,
|
||||
[unquote(option.section)],
|
||||
unquote(option.path),
|
||||
unquote(option.name),
|
||||
unquote(option.default)
|
||||
)
|
||||
|
@ -144,7 +193,7 @@ defmodule AshAuthentication.InfoGenerator do
|
|||
|
||||
case get_opt(
|
||||
dsl_or_resource,
|
||||
[unquote(option.section)],
|
||||
unquote(option.path),
|
||||
unquote(option.name),
|
||||
unquote(Map.get(option, :default, :error))
|
||||
) do
|
||||
|
@ -162,7 +211,7 @@ defmodule AshAuthentication.InfoGenerator do
|
|||
|
||||
case get_opt(
|
||||
dsl_or_resource,
|
||||
[unquote(option.section)],
|
||||
unquote(option.path),
|
||||
unquote(option.name),
|
||||
unquote(Map.get(option, :default, :error))
|
||||
) do
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
defmodule AshAuthentication.Jwt do
|
||||
@default_algorithm "HS256"
|
||||
@default_lifetime_hrs 7 * 24
|
||||
@default_lifetime_days 7
|
||||
@supported_algorithms Joken.Signer.algorithms()
|
||||
import AshAuthentication.Utils, only: [to_sentence: 2]
|
||||
|
||||
|
@ -24,7 +24,7 @@ defmodule AshAuthentication.Jwt do
|
|||
config :ash_authentication, #{inspect(__MODULE__)},
|
||||
signing_algorithm: #{inspect(@default_algorithm)}
|
||||
signing_secret: "I finally invent something that works!",
|
||||
token_lifetime: #{@default_lifetime_hrs} # #{@default_lifetime_hrs / 24.0} days
|
||||
token_lifetime: #{@default_lifetime_days * 24} # #{@default_lifetime_days} days
|
||||
```
|
||||
|
||||
Available signing algorithms are #{to_sentence(@supported_algorithms, final: "or")}. Defaults to #{@default_algorithm}.
|
||||
|
@ -34,12 +34,12 @@ defmodule AshAuthentication.Jwt do
|
|||
[`runtime.exs`](https://elixir-lang.org/getting-started/mix-otp/config-and-releases.html#configuration)
|
||||
and read it from the system environment or other secret store.
|
||||
|
||||
The default token lifetime is #{@default_lifetime_hrs} and should be specified
|
||||
The default token lifetime is #{@default_lifetime_days * 24} and should be specified
|
||||
in integer positive hours.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.Jwt.Config
|
||||
alias AshAuthentication.{Info, Jwt.Config}
|
||||
|
||||
@typedoc """
|
||||
A string likely to contain a valid JWT.
|
||||
|
@ -62,32 +62,35 @@ defmodule AshAuthentication.Jwt do
|
|||
|
||||
@doc "The default token lifetime"
|
||||
@spec default_lifetime_hrs :: pos_integer
|
||||
def default_lifetime_hrs, do: @default_lifetime_hrs
|
||||
def default_lifetime_hrs, do: @default_lifetime_days * 24
|
||||
|
||||
@doc """
|
||||
Given a record, generate a signed JWT for use while authenticating.
|
||||
Given a user, generate a signed JWT for use while authenticating.
|
||||
"""
|
||||
@spec token_for_record(Resource.record(), extra_claims :: %{}, options :: keyword) ::
|
||||
@spec token_for_user(Resource.record(), extra_claims :: %{}, options :: keyword) ::
|
||||
{:ok, token, claims} | :error
|
||||
def token_for_record(record, extra_claims \\ %{}, opts \\ []) do
|
||||
resource = record.__struct__
|
||||
def token_for_user(user, extra_claims \\ %{}, opts \\ []) do
|
||||
resource = user.__struct__
|
||||
|
||||
default_claims = Config.default_claims(resource, opts)
|
||||
signer = Config.token_signer(resource, opts)
|
||||
|
||||
subject = AshAuthentication.resource_to_subject(record)
|
||||
subject = AshAuthentication.user_to_subject(user)
|
||||
|
||||
extra_claims =
|
||||
extra_claims
|
||||
|> Map.put("sub", subject)
|
||||
|
||||
extra_claims =
|
||||
case Map.fetch(record.__metadata__, :tenant) do
|
||||
case Map.fetch(user.__metadata__, :tenant) do
|
||||
{:ok, tenant} -> Map.put(extra_claims, "tenant", to_string(tenant))
|
||||
:error -> extra_claims
|
||||
end
|
||||
|
||||
Joken.generate_and_sign(default_claims, extra_claims, signer)
|
||||
case Joken.generate_and_sign(default_claims, extra_claims, signer) do
|
||||
{:ok, token, claims} -> {:ok, token, claims}
|
||||
{:error, _reason} -> :error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
@ -99,11 +102,10 @@ defmodule AshAuthentication.Jwt do
|
|||
@doc """
|
||||
Given a token, verify it's signature and validate it's claims.
|
||||
"""
|
||||
@spec verify(token, Ash.Resource.t() | module) ::
|
||||
{:ok, claims, AshAuthentication.resource_config()} | :error
|
||||
@spec verify(token, Resource.t() | atom) :: {:ok, claims, Resource.t()} | :error
|
||||
def verify(token, otp_app_or_resource) do
|
||||
if function_exported?(otp_app_or_resource, :spark_is, 0) &&
|
||||
otp_app_or_resource.spark_is() == Ash.Resource do
|
||||
otp_app_or_resource.spark_is() == Resource do
|
||||
verify_for_resource(token, otp_app_or_resource)
|
||||
else
|
||||
verify_for_otp_app(token, otp_app_or_resource)
|
||||
|
@ -111,24 +113,23 @@ defmodule AshAuthentication.Jwt do
|
|||
end
|
||||
|
||||
defp verify_for_resource(token, resource) do
|
||||
with config <- AshAuthentication.resource_config(resource),
|
||||
signer <- Config.token_signer(resource),
|
||||
with signer <- Config.token_signer(resource),
|
||||
{:ok, claims} <- Joken.verify(token, signer),
|
||||
defaults <- Config.default_claims(resource),
|
||||
{:ok, claims} <- Joken.validate(defaults, claims, config) do
|
||||
{:ok, claims, config}
|
||||
{:ok, claims} <- Joken.validate(defaults, claims, resource) do
|
||||
{:ok, claims, resource}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_for_otp_app(token, otp_app) do
|
||||
with {:ok, config} <- token_to_resource(token, otp_app),
|
||||
signer <- Config.token_signer(config.resource),
|
||||
with {:ok, resource} <- token_to_resource(token, otp_app),
|
||||
signer <- Config.token_signer(resource),
|
||||
{:ok, claims} <- Joken.verify(token, signer),
|
||||
defaults <- Config.default_claims(config.resource),
|
||||
{:ok, claims} <- Joken.validate(defaults, claims, config) do
|
||||
{:ok, claims, config}
|
||||
defaults <- Config.default_claims(resource),
|
||||
{:ok, claims} <- Joken.validate(defaults, claims, resource) do
|
||||
{:ok, claims, resource}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
|
@ -142,21 +143,23 @@ defmodule AshAuthentication.Jwt do
|
|||
This function *does not* validate the token, so don't rely on it for
|
||||
authentication or authorisation.
|
||||
"""
|
||||
@spec token_to_resource(token, module) :: {:ok, AshAuthentication.resource_config()} | :error
|
||||
@spec token_to_resource(token, module) :: {:ok, Resource.t()} | :error
|
||||
def token_to_resource(token, otp_app) do
|
||||
with {:ok, %{"sub" => subject}} <- peek(token),
|
||||
%URI{path: subject_name} <- URI.parse(subject) do
|
||||
config_for_subject_name(subject_name, otp_app)
|
||||
resource_for_subject_name(subject_name, otp_app)
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp config_for_subject_name(subject_name, otp_app) do
|
||||
defp resource_for_subject_name(subject_name, otp_app) do
|
||||
otp_app
|
||||
|> AshAuthentication.authenticated_resources()
|
||||
|> Enum.find_value(:error, fn config ->
|
||||
if to_string(config.subject_name) == subject_name, do: {:ok, config}
|
||||
|> Enum.find_value(:error, fn resource ->
|
||||
with {:ok, resource_subject_name} <- Info.authentication_subject_name(resource),
|
||||
true <- subject_name == to_string(resource_subject_name),
|
||||
do: {:ok, resource}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -93,9 +93,9 @@ defmodule AshAuthentication.Jwt.Config do
|
|||
resource. Requires that the subject's resource configuration be passed as the
|
||||
validation context. This is automatically done by calling `Jwt.verify/2`.
|
||||
"""
|
||||
@spec validate_jti(String.t(), any, %{resource: module} | any) :: boolean
|
||||
def validate_jti(jti, _claims, %{resource: resource}) do
|
||||
case Info.tokens_revocation_resource(resource) do
|
||||
@spec validate_jti(String.t(), any, Resource.t() | any) :: boolean
|
||||
def validate_jti(jti, _claims, resource) when is_atom(resource) do
|
||||
case Info.authentication_tokens_revocation_resource(resource) do
|
||||
{:ok, revocation_resource} ->
|
||||
TokenRevocation.valid?(revocation_resource, jti)
|
||||
|
||||
|
@ -138,7 +138,7 @@ defmodule AshAuthentication.Jwt.Config do
|
|||
defp config(resource) do
|
||||
config =
|
||||
resource
|
||||
|> Info.tokens_options()
|
||||
|> Info.authentication_tokens_options()
|
||||
|> Enum.reject(&is_nil(elem(&1, 1)))
|
||||
|
||||
:ash_authentication
|
||||
|
|
|
@ -1,423 +0,0 @@
|
|||
defmodule AshAuthentication.OAuth2Authentication do
|
||||
@dsl [
|
||||
%Spark.Dsl.Section{
|
||||
name: :oauth2_authentication,
|
||||
describe: """
|
||||
Configure generic OAuth2 authentication for this resource.
|
||||
""",
|
||||
schema: [
|
||||
provider_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
A short name for the authentication provider.
|
||||
|
||||
Used in routes, etc.
|
||||
""",
|
||||
default: :oauth2
|
||||
],
|
||||
client_id: [
|
||||
type:
|
||||
{:spark_function_behaviour, AshAuthentication.Secret,
|
||||
{AshAuthentication.SecretFunction, 3}},
|
||||
doc: """
|
||||
The OAuth2 client ID.
|
||||
|
||||
Takes either a 2..3 arity anonymous function, or a module which
|
||||
implements the `AshAuthentication.Secret` behaviour.
|
||||
|
||||
See the module documentation for `AshAuthentication.Secret` for more
|
||||
information.
|
||||
""",
|
||||
required: true
|
||||
],
|
||||
site: [
|
||||
type:
|
||||
{:spark_function_behaviour, AshAuthentication.Secret,
|
||||
{AshAuthentication.SecretFunction, 3}},
|
||||
doc: """
|
||||
The base URL of the OAuth2 server.
|
||||
|
||||
Takes either a 2..3 arity anonymous function, or a module which
|
||||
implements the `AshAuthentication.Secret` behaviour.
|
||||
|
||||
See the module documentation for `AshAuthentication.Secret` for more
|
||||
information.
|
||||
""",
|
||||
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:
|
||||
{:spark_function_behaviour, AshAuthentication.Secret,
|
||||
{AshAuthentication.SecretFunction, 3}},
|
||||
doc: """
|
||||
The OAuth2 client secret.
|
||||
|
||||
Takes either a 2..3 arity anonymous function, or a module which
|
||||
implements the `AshAuthentication.Secret` behaviour.
|
||||
|
||||
See the module documentation for `AshAuthentication.Secret` for more
|
||||
information.
|
||||
|
||||
Required if :auth_method is `:client_secret_basic`, `:client_secret_post` or `:client_secret_jwt`.
|
||||
""",
|
||||
required: false
|
||||
],
|
||||
authorize_path: [
|
||||
type: :string,
|
||||
doc: "The API path to the OAuth2 authorize endpoint.",
|
||||
default: "/authorize"
|
||||
],
|
||||
token_path: [
|
||||
type: :string,
|
||||
doc: "The API path to access the token endpoint.",
|
||||
default: "/oauth/access_token"
|
||||
],
|
||||
user_path: [
|
||||
type: :string,
|
||||
doc: "The API path to access the user endpoint.",
|
||||
default: "/user"
|
||||
],
|
||||
private_key: [
|
||||
type:
|
||||
{:spark_function_behaviour, AshAuthentication.Secret,
|
||||
{AshAuthentication.SecretFunction, 3}},
|
||||
doc: """
|
||||
The private key to use if `:auth_method` is `:private_key_jwt`
|
||||
|
||||
Takes either a 2..3 arity anonymous function, or a module which
|
||||
implements the `AshAuthentication.Secret` behaviour.
|
||||
|
||||
See the module documentation for `AshAuthentication.Secret` for more
|
||||
information.
|
||||
""",
|
||||
required: false
|
||||
],
|
||||
redirect_uri: [
|
||||
type:
|
||||
{:spark_function_behaviour, AshAuthentication.Secret,
|
||||
{AshAuthentication.SecretFunction, 3}},
|
||||
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.
|
||||
|
||||
Takes either a 2..3 arity anonymous function, or a module which
|
||||
implements the `AshAuthentication.Secret` behaviour.
|
||||
|
||||
See the module documentation for `AshAuthentication.Secret` for more information.
|
||||
""",
|
||||
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: """
|
||||
""",
|
||||
default: true
|
||||
],
|
||||
sign_in_enabled?: [
|
||||
type: :boolean,
|
||||
doc: """
|
||||
""",
|
||||
default: false
|
||||
],
|
||||
register_action_name: [
|
||||
type: :atom,
|
||||
doc: ~S"""
|
||||
The name of the action to use to register a user.
|
||||
|
||||
Because we we don't know the response format of the server, you must
|
||||
implement your own registration action of the same name. Set to
|
||||
`false` to disable registration of new users.
|
||||
|
||||
See the "Registration and Sign-in" section of the module
|
||||
documentation for more information.
|
||||
|
||||
The default is computed from the `provider_name` eg:
|
||||
`register_with_#{provider_name}`.
|
||||
""",
|
||||
required: false
|
||||
],
|
||||
sign_in_action_name: [
|
||||
type: :atom,
|
||||
doc: ~S"""
|
||||
The name of the action to use to sign in an existing user.
|
||||
|
||||
Because we don't know the response format of the server, you must
|
||||
implement your own sign-in action of the same name. Set to `false`
|
||||
to disable signing in of existing users.
|
||||
|
||||
See the "Registration and Sign-in" section of the module
|
||||
documentation for more information.
|
||||
|
||||
The default is computed from the `provider_name`, eg:
|
||||
`sign_in_with_#{provider_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.ProviderIdentity` 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
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@moduledoc """
|
||||
Authentication using an external OAuth2 server as the source of truth.
|
||||
|
||||
This extension provides support for authenticating to a generic OAuth2 server.
|
||||
Use this if a service-specific strategy is not available for your
|
||||
authentication provider.
|
||||
|
||||
## Usage
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.User do
|
||||
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.OAuth2Authentication]
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :email, :ci_string, allow_nil?
|
||||
end
|
||||
|
||||
oauth2_authentication do
|
||||
client_id fn _, _, _ ->
|
||||
Application.fetch_env(:my_app, :oauth2_client_id)
|
||||
end
|
||||
|
||||
client_secret fn _, _, _ ->
|
||||
Application.fetch_env(:my_app, :oauth2_client_secret)
|
||||
end
|
||||
|
||||
site fn _, _, _ ->
|
||||
{:ok, "https://auth.example.com"}
|
||||
end)
|
||||
|
||||
redirect_uri fn _, _, _ ->
|
||||
{:ok, "https://localhost:4000/auth"}
|
||||
end
|
||||
end
|
||||
|
||||
actions do
|
||||
create :oauth2_register do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
|
||||
change AshAuthentication.GenerateTokenChange
|
||||
change MyApp.RegisterUser
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Identities
|
||||
|
||||
Given that it's possible for a user to be authenticated with more than one
|
||||
OAuth2 provider, we provide the `AshAuthentication.ProviderIdentity`
|
||||
extension. This extension dynamically generates a resource which can be used
|
||||
to keep track of which providers a user has authenticated with, and stores any
|
||||
tokens they may have in case you wish to make requests to the service on
|
||||
behalf of the user.
|
||||
|
||||
Additionally, for some providers, the provider identity resource can handle
|
||||
refreshing of access tokens before they expire.
|
||||
|
||||
## Registration and Sign-in
|
||||
|
||||
You can operate your OAuth2 authentication in either registration or sign-in
|
||||
mode. You do this by setting one of either `registration_enabled?` or
|
||||
`sign_in_enabled?` to `true`.
|
||||
|
||||
### Registration
|
||||
|
||||
When registration is enabled you will need to define a create action (see the
|
||||
`register_action_name` option for details).
|
||||
|
||||
This action will be called when a user successfully authenticates with the
|
||||
remote authentication provider and it will be passed two arguments:
|
||||
|
||||
* `user_info` which contains the [response from the OAuth2 user info
|
||||
endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse).
|
||||
* `oauth_tokens` which the [OAuth2 token
|
||||
response](https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse).
|
||||
|
||||
Add a change to this action which can use this information to build a user
|
||||
record, eg:
|
||||
|
||||
```elixir
|
||||
create :register_with_oauth2 do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
upsert? true
|
||||
upsert_identity :unique_email
|
||||
|
||||
change fn changeset, _ ->
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.change_attribute(:email, user_info["email"])
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
There are likely to be additional change modules required depending on your
|
||||
configuration options. These will be validated at compile time.
|
||||
|
||||
### Sign-in
|
||||
|
||||
When registration is disabled, you will need to define a sign-in action (see
|
||||
the `sign_in_action_name` option for details).
|
||||
|
||||
This action will be called with the same `user_info` and `oauth_tokens`
|
||||
arguments as the register action. You use this action to query for an
|
||||
existing user that matches your criteria, eg:
|
||||
|
||||
```elixir
|
||||
read :sign_in_with_oauth2 do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
prepare AshAuthentication.OAuth2Authentication.SignInPreparation
|
||||
|
||||
filter expr(email == get_path(^arg(:user_info), [:email]))
|
||||
end
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
This provider provides both `request` and `callback` endpoints to handle both
|
||||
phases of the request cycle.
|
||||
|
||||
## DSL Documentation
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index(@dsl)}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc(@dsl)}
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: @dsl,
|
||||
transformers: [AshAuthentication.OAuth2Authentication.Transformer]
|
||||
|
||||
use AshAuthentication.Provider
|
||||
|
||||
alias Ash.Resource
|
||||
alias Plug.Conn
|
||||
|
||||
@doc """
|
||||
The register action.
|
||||
|
||||
See "Registration and Sign-in" above.
|
||||
"""
|
||||
@impl true
|
||||
@spec register_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any}
|
||||
defdelegate register_action(resource, attributes), to: __MODULE__.Actions, as: :register
|
||||
|
||||
@doc """
|
||||
The sign-in action.
|
||||
|
||||
See "Registration and Sign-in" above.
|
||||
"""
|
||||
@impl true
|
||||
@spec sign_in_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any}
|
||||
defdelegate sign_in_action(resource, attributes), to: __MODULE__.Actions, as: :sign_in
|
||||
|
||||
@doc """
|
||||
The request plug.
|
||||
|
||||
Called by the router when a request which can be handled by this provider is
|
||||
received.
|
||||
"""
|
||||
@impl true
|
||||
@spec request_plug(Conn.t(), any) :: Conn.t()
|
||||
defdelegate request_plug(conn, config), to: __MODULE__.Plug, as: :request
|
||||
|
||||
@doc """
|
||||
The callback plug.
|
||||
|
||||
Called by the router when a user returns from the remote provider.
|
||||
"""
|
||||
@impl true
|
||||
@spec callback_plug(Conn.t(), any) :: Conn.t()
|
||||
defdelegate callback_plug(conn, config), to: __MODULE__.Plug, as: :callback
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec has_register_step?(Resource.t()) :: boolean
|
||||
def has_register_step?(_), do: false
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def provides(resource) do
|
||||
resource
|
||||
|> __MODULE__.Info.provider_name!()
|
||||
|> to_string()
|
||||
end
|
||||
end
|
|
@ -1,76 +0,0 @@
|
|||
defmodule AshAuthentication.OAuth2Authentication.Actions do
|
||||
@moduledoc """
|
||||
Code interface for oauth2 authentication actions.
|
||||
|
||||
Allows you to use the OAuth2 authentication provider without needing to mess
|
||||
with around with changesets, apis, etc. These functions are delegated to from
|
||||
within `AshAuthentication.OAuth2Authentication`.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Query, Resource}
|
||||
alias AshAuthentication.OAuth2Authentication, as: OAuth2
|
||||
|
||||
@doc """
|
||||
Attempt to register a user based on the `user_info` and `oauth_tokens` from a
|
||||
completed OAuth2 request.
|
||||
"""
|
||||
@spec register(Resource.t(), map) :: {:ok, Resource.record()} | {:error, term}
|
||||
def register(resource, attributes),
|
||||
do: register(resource, attributes, OAuth2.Info.registration_enabled?(resource))
|
||||
|
||||
defp register(resource, attributes, true) do
|
||||
action_name = OAuth2.Info.register_action_name!(resource)
|
||||
api = AshAuthentication.Info.authentication_api!(resource)
|
||||
action = Resource.Info.action(resource, action_name, :create)
|
||||
|
||||
resource
|
||||
|> Changeset.for_create(action_name, attributes,
|
||||
upsert?: true,
|
||||
upsert_identity: action.upsert_identity
|
||||
)
|
||||
|> api.create()
|
||||
end
|
||||
|
||||
defp register(resource, _attributes, false) do
|
||||
provider_name = OAuth2.Info.provider_name!(resource)
|
||||
|
||||
{:error,
|
||||
"""
|
||||
Registration of new #{provider_name} users is disabled for resource `#{inspect(resource)}`.
|
||||
|
||||
Hint: call `AshAuthentication.OAuth2Authentication.sign_in_action/2` instead.
|
||||
"""}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempt to sign in a user based on the `user_info` and `oauth_tokens` from a
|
||||
completed OAuth2 request.
|
||||
"""
|
||||
@spec sign_in(Resource.t(), map) :: {:ok, Resource.record()} | {:error, term}
|
||||
def sign_in(resource, attributes),
|
||||
do: sign_in(resource, attributes, OAuth2.Info.sign_in_enabled?(resource))
|
||||
|
||||
defp sign_in(resource, attributes, true) do
|
||||
action = OAuth2.Info.sign_in_action_name!(resource)
|
||||
api = AshAuthentication.Info.authentication_api!(resource)
|
||||
|
||||
resource
|
||||
|> Query.for_read(action, attributes)
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, [user]} -> {:ok, user}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp sign_in(resource, _attributes, false) do
|
||||
provider_name = OAuth2.Info.provider_name!(resource)
|
||||
|
||||
{:error,
|
||||
"""
|
||||
Signing in #{provider_name} users is disabled for resource `#{inspect(resource)}`.
|
||||
|
||||
Hint: call `AshAuthentication.OAuth2Authentication.register_action/2` instead.
|
||||
"""}
|
||||
end
|
||||
end
|
|
@ -1,32 +0,0 @@
|
|||
defmodule AshAuthentication.OAuth2Authentication.Html do
|
||||
@moduledoc """
|
||||
Renders a very basic sign-in button.
|
||||
"""
|
||||
|
||||
require EEx
|
||||
alias AshAuthentication.OAuth2Authentication, as: OAuth2
|
||||
|
||||
EEx.function_from_string(
|
||||
:defp,
|
||||
:render,
|
||||
~s"""
|
||||
<a href="<%= @action %>"><%= @legend %></a>
|
||||
""",
|
||||
[:assigns]
|
||||
)
|
||||
|
||||
@doc false
|
||||
@spec callback(module, keyword) :: String.t()
|
||||
def callback(_, _), do: ""
|
||||
|
||||
@doc false
|
||||
@spec request(module, keyword) :: String.t()
|
||||
def request(resource, options) do
|
||||
options
|
||||
|> Map.new()
|
||||
|> Map.merge(OAuth2.Info.options(resource))
|
||||
|> Map.merge(AshAuthentication.Info.authentication_options(resource))
|
||||
|> Map.put_new(:legend, "Sign in with #{OAuth2.provides(resource)}")
|
||||
|> render()
|
||||
end
|
||||
end
|
|
@ -1,45 +0,0 @@
|
|||
defmodule AshAuthentication.OAuth2Authentication.IdentityChange do
|
||||
@moduledoc """
|
||||
Updates the identity resource when a user is registered.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias AshAuthentication.OAuth2Authentication, as: OAuth2
|
||||
alias AshAuthentication.ProviderIdentity
|
||||
alias Ash.{Changeset, Resource.Change}
|
||||
import AshAuthentication.Utils, only: [is_falsy: 1]
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
identity_resource = OAuth2.Info.identity_resource!(changeset.resource)
|
||||
maybe_change(changeset, identity_resource)
|
||||
end
|
||||
|
||||
defp maybe_change(changeset, falsy) when is_falsy(falsy), do: changeset
|
||||
|
||||
defp maybe_change(changeset, identity_resource) do
|
||||
identity_relationship = OAuth2.Info.identity_relationship_name!(changeset.resource)
|
||||
provider_name = OAuth2.Info.provider_name!(changeset.resource)
|
||||
|
||||
changeset
|
||||
|> Changeset.after_action(fn changeset, user ->
|
||||
identity_resource
|
||||
|> ProviderIdentity.Actions.upsert(%{
|
||||
user_info: Changeset.get_argument(changeset, :user_info),
|
||||
oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens),
|
||||
provider: provider_name,
|
||||
user_id: user.id
|
||||
})
|
||||
|> case do
|
||||
{:ok, _identity} ->
|
||||
user
|
||||
|> changeset.api.load(identity_relationship)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -1,88 +0,0 @@
|
|||
defmodule AshAuthentication.OAuth2Authentication.Info do
|
||||
@moduledoc """
|
||||
Generated configuration functions based on a resource's DSL configuration.
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication.OAuth2Authentication,
|
||||
sections: [:oauth2_authentication]
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.OAuth2Authentication, as: OAuth2
|
||||
|
||||
@doc """
|
||||
Returns the resource configuration in a format ready for use by `Assent`.
|
||||
"""
|
||||
@spec resource_config(Resource.t()) :: {:ok, keyword} | {:error, any}
|
||||
def resource_config(resource) do
|
||||
with {:ok, auth_method} <- auth_method(resource),
|
||||
{:ok, client_id} <- fetch_secret(resource, :client_id),
|
||||
{:ok, client_secret} <- get_secret(resource, :client_secret),
|
||||
{:ok, private_key} <- get_secret(resource, :private_key),
|
||||
{:ok, jwt_algorithm} <-
|
||||
AshAuthentication.Info.tokens_signing_algorithm(resource),
|
||||
{:ok, authorization_params} <- authorization_params(resource),
|
||||
{:ok, redirect_uri} <- fetch_secret(resource, :redirect_uri),
|
||||
{:ok, site} <- fetch_secret(resource, :site),
|
||||
{:ok, authorize_path} <- authorize_path(resource),
|
||||
{:ok, token_path} <- token_path(resource),
|
||||
{:ok, user_path} <- user_path(resource) do
|
||||
config =
|
||||
[
|
||||
auth_method: auth_method,
|
||||
client_id: client_id,
|
||||
client_secret: client_secret,
|
||||
private_key: private_key,
|
||||
jwt_algoirthm: jwt_algorithm,
|
||||
authorization_params: authorization_params,
|
||||
redirect_uri: build_redirect_uri(redirect_uri, resource),
|
||||
site: site,
|
||||
authorize_url: append_uri_path(site, authorize_path),
|
||||
token_url: append_uri_path(site, token_path),
|
||||
user_url: append_uri_path(site, user_path),
|
||||
http_adapter: Assent.HTTPAdapter.Mint
|
||||
]
|
||||
|> Enum.reject(&is_nil(elem(&1, 1)))
|
||||
|
||||
{:ok, config}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_secret(resource, secret_name) do
|
||||
with {:ok, {secret_module, secret_opts}} <- apply(__MODULE__, secret_name, [resource]),
|
||||
{:ok, secret} when is_binary(secret) and byte_size(secret) > 0 <-
|
||||
secret_module.secret_for([:oauth2_authentication, secret_name], resource, secret_opts) do
|
||||
{:ok, secret}
|
||||
else
|
||||
_ -> {:error, {:missing_secret, secret_name}}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_secret(resource, secret_name) do
|
||||
case fetch_secret(resource, secret_name) do
|
||||
{:ok, secret} -> {:ok, secret}
|
||||
_ -> {:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_redirect_uri(base, resource) do
|
||||
uri = URI.new!(base)
|
||||
config = AshAuthentication.resource_config(resource)
|
||||
|
||||
path =
|
||||
Path.join([
|
||||
uri.path || "/",
|
||||
to_string(config.subject_name),
|
||||
OAuth2.provides(resource),
|
||||
"callback"
|
||||
])
|
||||
|
||||
%URI{uri | path: path} |> to_string()
|
||||
end
|
||||
|
||||
defp append_uri_path(base, path) do
|
||||
uri = URI.new!(base)
|
||||
path = Path.join(uri.path || "/", path)
|
||||
%URI{uri | path: path} |> to_string()
|
||||
end
|
||||
end
|
|
@ -1,94 +0,0 @@
|
|||
defmodule AshAuthentication.OAuth2Authentication.Plug do
|
||||
@moduledoc """
|
||||
Handlers for incoming OAuth2 HTTP requests.
|
||||
"""
|
||||
|
||||
import AshAuthentication.Plug.Helpers, only: [private_store: 2]
|
||||
alias AshAuthentication.Errors.AuthenticationFailed
|
||||
alias AshAuthentication.OAuth2Authentication, as: OAuth2
|
||||
alias Assent.Strategy.OAuth2, as: Strategy
|
||||
alias Plug.Conn
|
||||
import Plug.Conn
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Perform the request phase of OAuth2.
|
||||
|
||||
Builds a redirection URL based on the provider configuration and redirects the
|
||||
user to that endpoint.
|
||||
"""
|
||||
@spec request(Conn.t(), any) :: Conn.t()
|
||||
def request(conn, _opts) when is_map(conn.private.authenticator) do
|
||||
config = conn.private.authenticator
|
||||
|
||||
with {:ok, provider_name} <- OAuth2.Info.provider_name(config.resource),
|
||||
{:ok, resource_config} <- OAuth2.Info.resource_config(config.resource),
|
||||
{:ok, %{session_params: session_params, url: url}} <-
|
||||
Strategy.authorize_url(resource_config) do
|
||||
conn
|
||||
|> put_session(session_key(config), session_params)
|
||||
|> put_resp_header("location", url)
|
||||
|> send_resp(:found, "Redirecting to #{provider_name}")
|
||||
else
|
||||
:error ->
|
||||
Logger.error(
|
||||
"Configuration error with OAuth2 configuration for `#{inspect(config.resource)}`"
|
||||
)
|
||||
|
||||
conn
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(
|
||||
"Configuration error with OAuth2 configuration for `#{inspect(config.resource)}`: #{inspect(reason)}`"
|
||||
)
|
||||
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Perform the callback phase of OAuth2.
|
||||
|
||||
Responds to a user being redirected back from the remote authentication
|
||||
provider, and validates the passed options, ultimately registering or
|
||||
signing-in a user if the authentication was successful.
|
||||
"""
|
||||
@spec callback(Conn.t(), any) :: Conn.t()
|
||||
def callback(conn, _opts) when is_map(conn.private.authenticator) do
|
||||
config = conn.private.authenticator
|
||||
|
||||
with {:ok, resource_config} <- OAuth2.Info.resource_config(config.resource),
|
||||
session_key <- session_key(config),
|
||||
session_params when is_map(session_params) <- get_session(conn, session_key),
|
||||
conn <- delete_session(conn, session_key),
|
||||
resource_config <- Assent.Config.put(resource_config, :session_params, session_params),
|
||||
{:ok, %{user: user, token: token}} <- Strategy.callback(resource_config, conn.params),
|
||||
{:ok, user} <- register_or_sign_in_user(config, %{user_info: user, oauth_tokens: token}) do
|
||||
private_store(conn, {:success, user})
|
||||
else
|
||||
{:error, reason} -> private_store(conn, {:failure, reason})
|
||||
_ -> conn
|
||||
end
|
||||
end
|
||||
|
||||
# We need to temporarily store some information about the request in the
|
||||
# session so that we can verify that there hasn't been a CSRF-related attack.
|
||||
defp session_key(config),
|
||||
do: "#{config.subject_name}/#{config.provider.provides(config.resource)}"
|
||||
|
||||
defp register_or_sign_in_user(config, params) do
|
||||
registration_enabled? = OAuth2.Info.registration_enabled?(config.resource)
|
||||
sign_in_enabled? = OAuth2.Info.sign_in_enabled?(config.resource)
|
||||
|
||||
cond do
|
||||
registration_enabled? ->
|
||||
OAuth2.register_action(config.resource, params)
|
||||
|
||||
sign_in_enabled? ->
|
||||
OAuth2.sign_in_action(config.resource, params)
|
||||
|
||||
true ->
|
||||
{:error, AuthenticationFailed.exception([])}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,72 +0,0 @@
|
|||
defmodule AshAuthentication.OAuth2Authentication.SignInPreparation do
|
||||
@moduledoc """
|
||||
Prepare a query for sign in
|
||||
|
||||
Performs three main tasks:
|
||||
|
||||
1. Ensures that there is only one matching user record returned, otherwise
|
||||
returns an authentication failed error.
|
||||
2. Generates an access token if token generation is enabled.
|
||||
3. Updates the user identity resource, if one is enabled.
|
||||
"""
|
||||
use Ash.Resource.Preparation
|
||||
alias AshAuthentication.OAuth2Authentication, as: OAuth2
|
||||
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, ProviderIdentity}
|
||||
alias Ash.{Query, Resource.Preparation}
|
||||
require Ash.Query
|
||||
import AshAuthentication.Utils, only: [is_falsy: 1]
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
|
||||
def prepare(query, _opts, _context) do
|
||||
query
|
||||
|> Query.after_action(fn
|
||||
query, [user] ->
|
||||
with {:ok, user} <- maybe_update_identity(user, query) do
|
||||
{:ok, [maybe_generate_token(user)]}
|
||||
end
|
||||
|
||||
_, _ ->
|
||||
{:error, AuthenticationFailed.exception(query: query)}
|
||||
end)
|
||||
end
|
||||
|
||||
defp maybe_update_identity(user, query) do
|
||||
case OAuth2.Info.identity_resource(query.resource) do
|
||||
{:ok, falsy} when is_falsy(falsy) ->
|
||||
user
|
||||
|
||||
:error ->
|
||||
user
|
||||
|
||||
{:ok, resource} ->
|
||||
identity_relationship = OAuth2.Info.identity_relationship_name!(query.resource)
|
||||
|
||||
resource
|
||||
|> ProviderIdentity.Actions.upsert(%{
|
||||
user_info: Query.get_argument(query, :user_info),
|
||||
oauth_tokens: Query.get_argument(query, :oauth_tokens),
|
||||
provider: OAuth2.Info.provider_name!(query.resource),
|
||||
user_id: user.id
|
||||
})
|
||||
|> case do
|
||||
{:ok, _identity} ->
|
||||
user
|
||||
|> query.api.load(identity_relationship)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_generate_token(user) do
|
||||
if AshAuthentication.Info.tokens_enabled?(user.__struct__) do
|
||||
{:ok, token, _claims} = Jwt.token_for_record(user)
|
||||
%{user | __metadata__: Map.put(user.__metadata__, :token, token)}
|
||||
else
|
||||
user
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,205 +0,0 @@
|
|||
defmodule AshAuthentication.OAuth2Authentication.Transformer do
|
||||
@moduledoc """
|
||||
The OAuth2Authentication Authentication transformer.
|
||||
|
||||
Scans the resource and checks that all the fields and actions needed are
|
||||
present.
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.GenerateTokenChange
|
||||
alias AshAuthentication.OAuth2Authentication, as: OAuth2
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
|
||||
import AshAuthentication.Validations
|
||||
import AshAuthentication.Validations.Action
|
||||
import AshAuthentication.Utils
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec transform(map) ::
|
||||
:ok
|
||||
| {:ok, map()}
|
||||
| {:error, term()}
|
||||
| {:warn, map(), String.t() | [String.t()]}
|
||||
| :halt
|
||||
def transform(dsl_state) do
|
||||
with :ok <- validate_extension(dsl_state, AshAuthentication),
|
||||
{:ok, identity_resource} <- OAuth2.Info.identity_resource(dsl_state),
|
||||
{:ok, dsl_state} <- maybe_build_identity_relationship(dsl_state, identity_resource),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_set_action_name(dsl_state, :register_action_name, "register_with_"),
|
||||
{:ok, register_action_name} <- OAuth2.Info.register_action_name(dsl_state),
|
||||
registration_enabled? <- OAuth2.Info.registration_enabled?(dsl_state),
|
||||
:ok <- validate_register_action(dsl_state, register_action_name, registration_enabled?),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_set_action_name(dsl_state, :sign_in_action_name, "sign_in_with_"),
|
||||
{:ok, sign_in_action_name} <- OAuth2.Info.sign_in_action_name(dsl_state),
|
||||
sign_in_enabled? <- OAuth2.Info.sign_in_enabled?(dsl_state),
|
||||
:ok <- validate_sign_in_action(dsl_state, sign_in_action_name, sign_in_enabled?),
|
||||
:ok <- validate_only_one_action_enabled(dsl_state) do
|
||||
authentication =
|
||||
Transformer.get_persisted(dsl_state, :authentication)
|
||||
|> Map.update(
|
||||
:providers,
|
||||
[AshAuthentication.OAuth2Authentication],
|
||||
&[AshAuthentication.OAuth2Authentication | &1]
|
||||
)
|
||||
|
||||
dsl_state =
|
||||
dsl_state
|
||||
|> Transformer.persist(:authentication, authentication)
|
||||
|
||||
{:ok, dsl_state}
|
||||
else
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:error, DslError.exception(path: [:oauth2_authentication], message: reason)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
||||
:error ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:oauth2_authentication],
|
||||
message: "Configuration error while validating `oauth2_authentication`."
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@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?(Resource.Transformers.HasDestinationField), do: true
|
||||
def before?(Resource.Transformers.SetRelationshipSource), do: true
|
||||
def before?(Resource.Transformers.ValidateRelationshipAttributes), do: true
|
||||
def before?(_), do: false
|
||||
|
||||
defp validate_only_one_action_enabled(dsl_state) do
|
||||
registration_enabled? = OAuth2.Info.registration_enabled?(dsl_state)
|
||||
sign_in_enabled? = OAuth2.Info.sign_in_enabled?(dsl_state)
|
||||
|
||||
case {registration_enabled?, sign_in_enabled?} do
|
||||
{true, true} ->
|
||||
{:error, "Only one of `registration_enabled?` and `sign_in_enabled?` can be set."}
|
||||
|
||||
{false, false} ->
|
||||
{:error, "One of either `registration_enabled?` and `sign_in_enabled?` must be set."}
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_set_action_name(dsl_state, option, prefix) do
|
||||
cfg = OAuth2.Info.options(dsl_state)
|
||||
|
||||
case Map.fetch(cfg, option) do
|
||||
{:ok, _value} ->
|
||||
{:ok, dsl_state}
|
||||
|
||||
:error ->
|
||||
action_name = String.to_atom("#{prefix}#{cfg.provider_name}")
|
||||
{:ok, Transformer.set_option(dsl_state, [:oauth2_authentication], option, action_name)}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_build_identity_relationship(dsl_state, falsy) when is_falsy(falsy),
|
||||
do: {:ok, dsl_state}
|
||||
|
||||
defp maybe_build_identity_relationship(dsl_state, identity_resource) do
|
||||
with {:ok, identity_relationship} <- OAuth2.Info.identity_relationship_name(dsl_state) do
|
||||
maybe_build_relationship(
|
||||
dsl_state,
|
||||
identity_relationship,
|
||||
&build_identity_relationship(&1, identity_relationship, identity_resource)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_register_action(_dsl_state, _action_name, false), do: :ok
|
||||
|
||||
defp validate_register_action(dsl_state, action_name, true) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
:ok <- validate_action_has_argument(action, :user_info),
|
||||
:ok <- validate_action_argument_option(action, :user_info, :type, [Ash.Type.Map, :map]),
|
||||
:ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]),
|
||||
:ok <- validate_action_has_argument(action, :oauth_tokens),
|
||||
:ok <-
|
||||
validate_action_argument_option(action, :oauth_tokens, :type, [Ash.Type.Map, :map]),
|
||||
:ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]),
|
||||
:ok <- maybe_validate_action_has_token_change(dsl_state, action),
|
||||
:ok <- validate_field_in_values(action, :upsert?, [true]),
|
||||
:ok <-
|
||||
validate_field_with(
|
||||
action,
|
||||
:upsert_identity,
|
||||
&(is_atom(&1) and not is_falsy(&1)),
|
||||
"Expected `upsert_identity` to be set"
|
||||
),
|
||||
{:ok, identity_resource} <- OAuth2.Info.identity_resource(dsl_state),
|
||||
:ok <- maybe_validate_action_has_identity_change(action, identity_resource) do
|
||||
:ok
|
||||
else
|
||||
:error ->
|
||||
{:error, "Unable to validate register action"}
|
||||
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:error, "`#{inspect(action_name)}` action: #{reason}"}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_sign_in_action(_dsl_state, _action_name, false), do: :ok
|
||||
|
||||
defp validate_sign_in_action(dsl_state, action_name, true) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
:ok <- validate_action_has_argument(action, :user_info),
|
||||
:ok <- validate_action_argument_option(action, :user_info, :type, [Ash.Type.Map, :map]),
|
||||
:ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]),
|
||||
:ok <- validate_action_has_argument(action, :oauth_tokens),
|
||||
:ok <-
|
||||
validate_action_argument_option(action, :oauth_tokens, :type, [Ash.Type.Map, :map]),
|
||||
:ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]),
|
||||
:ok <- validate_action_has_preparation(action, OAuth2.SignInPreparation) do
|
||||
:ok
|
||||
else
|
||||
:error -> {:error, "Unable to validate sign in action"}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_validate_action_has_token_change(dsl_state, action) do
|
||||
if AshAuthentication.Info.tokens_enabled?(dsl_state) do
|
||||
validate_action_has_change(action, GenerateTokenChange)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp build_identity_relationship(dsl_state, name, destination) do
|
||||
with {:ok, dest_attr} <- OAuth2.Info.identity_relationship_user_id_attribute(dsl_state) do
|
||||
Transformer.build_entity(Resource.Dsl, [:relationships], :has_many,
|
||||
name: name,
|
||||
destination: destination,
|
||||
destination_attribute: dest_attr
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_validate_action_has_identity_change(_action, falsy) when is_falsy(falsy), do: :ok
|
||||
|
||||
defp maybe_validate_action_has_identity_change(action, _identity_resource),
|
||||
do: validate_action_has_change(action, OAuth2.IdentityChange)
|
||||
end
|
|
@ -1,201 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication do
|
||||
@dsl [
|
||||
%Spark.Dsl.Section{
|
||||
name: :password_authentication,
|
||||
describe: """
|
||||
Configure password authentication authentication for this resource.
|
||||
""",
|
||||
schema: [
|
||||
identity_field: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the attribute which uniquely identifies the actor.
|
||||
|
||||
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",
|
||||
default: :register
|
||||
],
|
||||
sign_in_action_name: [
|
||||
type: :atom,
|
||||
doc: "The name to use for the sign in action",
|
||||
default: :sign_in
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@moduledoc """
|
||||
Authentication using your application as the source of truth.
|
||||
|
||||
This extension provides an authentication mechanism for authenticating with a
|
||||
username (or other unique identifier) and password.
|
||||
|
||||
## Usage
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.User do
|
||||
use Ash.Resource, extensions: [AshAuthentication.PasswordAuthentication]
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :username, :ci_string, allow_nil?: false
|
||||
attribute :hashed_password, :string, allow_nil?: false
|
||||
end
|
||||
|
||||
password_authentication do
|
||||
identity_field :username
|
||||
password_field :password
|
||||
password_confirmation_field :password_confirmation
|
||||
hashed_password_field :hashed_password
|
||||
hash_provider AshAuthentication.BcryptProvider
|
||||
confirmation_required? true
|
||||
end
|
||||
|
||||
authentication do
|
||||
api MyApp.Accounts
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
This provider routes requests to both the `request` and `callback` endpoints
|
||||
to the same handler, so either can be used. Requests are differentiated by
|
||||
the presence of an `action` parameter in the request body.
|
||||
|
||||
### Examples
|
||||
|
||||
When attempting to register a new user
|
||||
|
||||
```
|
||||
%{"user" => %{
|
||||
"action" => "register",
|
||||
"email" => "marty@mcfly.me",
|
||||
"password" => "back to 1985",
|
||||
"password_confirmation" => "back to 1985"
|
||||
# any additional user fields you wish to accept on creation.
|
||||
}}
|
||||
```
|
||||
|
||||
When attempting to sign-in a user
|
||||
|
||||
```
|
||||
%{"user" => %{
|
||||
"action" => "sign_in",
|
||||
"email" => "marty@mcfly.me",
|
||||
"password" => "back to 1985"
|
||||
}}
|
||||
```
|
||||
|
||||
## DSL Documentation
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index(@dsl)}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc(@dsl)}
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: @dsl,
|
||||
transformers: [AshAuthentication.PasswordAuthentication.Transformer]
|
||||
|
||||
use AshAuthentication.Provider
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.PasswordAuthentication
|
||||
|
||||
@doc """
|
||||
Attempt to sign in an user of the provided resource type.
|
||||
|
||||
## Example
|
||||
|
||||
iex> sign_in_action(MyApp.User, %{username: "marty", password: "its_1985"})
|
||||
{:ok, #MyApp.User<>}
|
||||
"""
|
||||
@impl true
|
||||
@spec sign_in_action(Resource.t(), map) :: {:ok, struct} | {:error, term}
|
||||
defdelegate sign_in_action(resource, attributes),
|
||||
to: PasswordAuthentication.Actions,
|
||||
as: :sign_in
|
||||
|
||||
@doc """
|
||||
Attempt to register an user of the provided resource type.
|
||||
|
||||
## Example
|
||||
|
||||
iex> register(MyApp.User, %{username: "marty", password: "its_1985", password_confirmation: "its_1985"})
|
||||
{:ok, #MyApp.User<>}
|
||||
"""
|
||||
@impl true
|
||||
@spec register_action(Resource.t(), map) :: {:ok, struct} | {:error, term}
|
||||
defdelegate register_action(resource, attributes),
|
||||
to: PasswordAuthentication.Actions,
|
||||
as: :register
|
||||
|
||||
@doc """
|
||||
Handle the callback phase.
|
||||
|
||||
Handles both sign-in and registration actions via the same endpoint.
|
||||
"""
|
||||
@impl true
|
||||
defdelegate callback_plug(conn, config), to: PasswordAuthentication.Plug, as: :handle
|
||||
|
||||
@doc """
|
||||
Handle the request phase.
|
||||
|
||||
Handles both sign-in and registration actions via the same endpoint.
|
||||
"""
|
||||
@impl true
|
||||
defdelegate request_plug(conn, config), to: PasswordAuthentication.Plug, as: :handle
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec has_register_step?(Resource.t()) :: boolean
|
||||
def has_register_step?(_resource), do: true
|
||||
end
|
|
@ -1,57 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.Actions do
|
||||
@moduledoc """
|
||||
Code interface for password authentication.
|
||||
|
||||
Allows you to use the password authentication provider without needing to mess
|
||||
around with changesets, apis, etc. These functions are delegated to from
|
||||
within `AshAuthentication.PasswordAuthentication`.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Query}
|
||||
alias AshAuthentication.PasswordAuthentication
|
||||
|
||||
@doc """
|
||||
Attempt to sign in an user of the provided resource type.
|
||||
|
||||
## Example
|
||||
|
||||
iex> sign_in(MyApp.User, %{username: "marty", password: "its_1985"})
|
||||
{:ok, #MyApp.User<>}
|
||||
"""
|
||||
@spec sign_in(module, map) :: {:ok, struct} | {:error, term}
|
||||
def sign_in(resource, attributes) do
|
||||
{:ok, action} =
|
||||
PasswordAuthentication.Info.password_authentication_sign_in_action_name(resource)
|
||||
|
||||
{:ok, api} = AshAuthentication.Info.authentication_api(resource)
|
||||
|
||||
resource
|
||||
|> Query.for_read(action, attributes)
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, [user]} -> {:ok, user}
|
||||
{:ok, []} -> {:error, "Invalid username or password"}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempt to register an user of the provided resource type.
|
||||
|
||||
## Example
|
||||
|
||||
iex> register(MyApp.User, %{username: "marty", password: "its_1985", password_confirmation: "its_1985"})
|
||||
{:ok, #MyApp.User<>}
|
||||
"""
|
||||
@spec register(module, map) :: {:ok, struct} | {:error, term}
|
||||
def register(resource, attributes) do
|
||||
{:ok, action} =
|
||||
PasswordAuthentication.Info.password_authentication_register_action_name(resource)
|
||||
|
||||
{:ok, api} = AshAuthentication.Info.authentication_api(resource)
|
||||
|
||||
resource
|
||||
|> Changeset.for_create(action, attributes)
|
||||
|> api.create()
|
||||
end
|
||||
end
|
|
@ -1,32 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.HashPasswordChange do
|
||||
@moduledoc """
|
||||
Set the hash based on the password input.
|
||||
|
||||
Uses the configured `AshAuthentication.HashProvider` to generate a hash of the
|
||||
user's password input and store it in the changeset.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias AshAuthentication.PasswordAuthentication.Info
|
||||
alias Ash.{Changeset, Resource.Change}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _) do
|
||||
changeset
|
||||
|> Changeset.before_action(fn changeset ->
|
||||
{:ok, password_field} = Info.password_authentication_password_field(changeset.resource)
|
||||
{:ok, hash_field} = Info.password_authentication_hashed_password_field(changeset.resource)
|
||||
{:ok, hasher} = Info.password_authentication_hash_provider(changeset.resource)
|
||||
|
||||
with value when is_binary(value) <- Changeset.get_argument(changeset, password_field),
|
||||
{:ok, hash} <- hasher.hash(value) do
|
||||
Changeset.change_attribute(changeset, hash_field, hash)
|
||||
else
|
||||
nil -> changeset
|
||||
:error -> {:error, "Error hashing password"}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -1,105 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.Html do
|
||||
@moduledoc """
|
||||
Renders a very basic form for using password authentication.
|
||||
|
||||
These are mainly used for testing, and you should instead write your own or
|
||||
use the widgets in `ash_authentication_phoenix`.
|
||||
"""
|
||||
|
||||
require EEx
|
||||
alias AshAuthentication.PasswordAuthentication
|
||||
|
||||
EEx.function_from_string(
|
||||
:defp,
|
||||
:render_sign_in,
|
||||
~s"""
|
||||
<form method="<%= @method %>" action="<%= @action %>">
|
||||
<input type="hidden" name="<%= @subject_name %>[action]" value="sign_in" />
|
||||
<fieldset>
|
||||
<%= if @legend do %><legend><%= @legend %></legend><% end %>
|
||||
<input type="text" name="<%= @subject_name %>[<%= @identity_field %>]" placeholder="<%= @identity_field %>" />
|
||||
<br />
|
||||
<input type="password" name="<%= @subject_name %>[<%= @password_field %>]" placeholder="Password" />
|
||||
<br />
|
||||
<input type="submit" value="Sign in" />
|
||||
</fieldset>
|
||||
</form>
|
||||
""",
|
||||
[:assigns]
|
||||
)
|
||||
|
||||
EEx.function_from_string(
|
||||
:defp,
|
||||
:render_register,
|
||||
~s"""
|
||||
<form method="<%= @method %>" action="<%= @action %>">
|
||||
<input type="hidden" name="<%= @subject_name %>[action]" value="<%= @register_action_name %>" />
|
||||
<fieldset>
|
||||
<%= if @legend do %><legend><%= @legend %></legend><% end %>
|
||||
<input type="text" name="<%= @subject_name %>[<%= @identity_field %>]" placeholder="<%= @identity_field %>" />
|
||||
<br />
|
||||
<input type="password" name="<%= @subject_name %>[<%= @password_field %>]" placeholder="Password" />
|
||||
<br />
|
||||
<%= if @confirmation_required? do %>
|
||||
<input type="password" name="<%= @subject_name %>[<%= @password_confirmation_field %>]" placeholder="Password confirmation" />
|
||||
<br />
|
||||
<% end %>
|
||||
<input type="submit" value="Register" />
|
||||
</fieldset>
|
||||
</form>
|
||||
""",
|
||||
[:assigns]
|
||||
)
|
||||
|
||||
@defaults [method: "POST", legend: nil]
|
||||
|
||||
@type options :: [method_option | action_option]
|
||||
|
||||
@typedoc """
|
||||
The HTTP method used to submit the form.
|
||||
|
||||
Defaults to `#{inspect(Keyword.get(@defaults, :method))}`.
|
||||
"""
|
||||
@type method_option :: {:method, String.t()}
|
||||
|
||||
@typedoc """
|
||||
The path/URL to which the form should be submitted.
|
||||
"""
|
||||
@type action_option :: {:action, String.t()}
|
||||
|
||||
@doc """
|
||||
Render a basic HTML sign-in form.
|
||||
"""
|
||||
@spec callback(module, options) :: String.t()
|
||||
def callback(resource, options) do
|
||||
options =
|
||||
options
|
||||
|> Keyword.put_new(:legend, "Sign in")
|
||||
|
||||
resource
|
||||
|> build_assigns(options)
|
||||
|> render_sign_in()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render a basic HTML registration form.
|
||||
"""
|
||||
@spec request(module, options) :: String.t()
|
||||
def request(resource, options) do
|
||||
options =
|
||||
options
|
||||
|> Keyword.put_new(:legend, "Register")
|
||||
|
||||
resource
|
||||
|> build_assigns(options)
|
||||
|> render_register()
|
||||
end
|
||||
|
||||
defp build_assigns(resource, options) do
|
||||
@defaults
|
||||
|> Keyword.merge(options)
|
||||
|> Map.new()
|
||||
|> Map.merge(PasswordAuthentication.Info.password_authentication_options(resource))
|
||||
|> Map.merge(AshAuthentication.Info.authentication_options(resource))
|
||||
end
|
||||
end
|
|
@ -1,10 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.Info do
|
||||
@moduledoc """
|
||||
Generated configuration functions based on a resource's DSL configuration.
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication.PasswordAuthentication,
|
||||
sections: [:password_authentication],
|
||||
prefix?: true
|
||||
end
|
|
@ -1,32 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.PasswordConfirmationValidation do
|
||||
@moduledoc """
|
||||
Validate that the password and password confirmation match.
|
||||
|
||||
This check is only performed when the `confirmation_required?` DSL option is set to `true`.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Validation
|
||||
alias Ash.{Changeset, Error.Changes.InvalidArgument}
|
||||
alias AshAuthentication.PasswordAuthentication.Info
|
||||
|
||||
@doc """
|
||||
Validates that the password and password confirmation fields contain
|
||||
equivalent values - if confirmation is required.
|
||||
"""
|
||||
@impl true
|
||||
@spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()}
|
||||
def validate(changeset, _) do
|
||||
with true <- Info.password_authentication_confirmation_required?(changeset.resource),
|
||||
{:ok, password_field} <- Info.password_authentication_password_field(changeset.resource),
|
||||
{:ok, confirm_field} <-
|
||||
Info.password_authentication_password_confirmation_field(changeset.resource),
|
||||
password <- Changeset.get_argument(changeset, password_field),
|
||||
confirmation <- Changeset.get_argument(changeset, confirm_field),
|
||||
false <- password == confirmation do
|
||||
{:error, InvalidArgument.exception(field: confirm_field, message: "does not match")}
|
||||
else
|
||||
:error -> {:error, "Password confirmation required, but not configured"}
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,46 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.Plug do
|
||||
@moduledoc """
|
||||
Handlers for incoming HTTP requests.
|
||||
|
||||
AshAuthentication is written with an eye towards OAuth which uses a two-phase
|
||||
request/callback process which can be used to register and sign in an user in
|
||||
a single flow. This doesn't really work that well with
|
||||
`PasswordAuthentication` which has seperate "registration" and "sign-in"
|
||||
actions.
|
||||
|
||||
We handle both registration and sign in by passing an "action" parameter along
|
||||
with the form data.
|
||||
"""
|
||||
import AshAuthentication.Plug.Helpers, only: [private_store: 2]
|
||||
alias AshAuthentication.PasswordAuthentication
|
||||
alias Plug.Conn
|
||||
|
||||
@doc """
|
||||
Handle the callback phase.
|
||||
|
||||
Handles both sign-in and registration actions via the same endpoint.
|
||||
"""
|
||||
@spec handle(Conn.t(), any) :: Conn.t()
|
||||
def handle(%{params: params, private: %{authenticator: config}} = conn, _opts) do
|
||||
params
|
||||
|> Map.get(to_string(config.subject_name), %{})
|
||||
|> do_action(config.resource)
|
||||
|> case do
|
||||
{:ok, user} when is_struct(user, config.resource) ->
|
||||
private_store(conn, {:success, user})
|
||||
|
||||
{:error, changeset} ->
|
||||
private_store(conn, {:failure, changeset})
|
||||
end
|
||||
end
|
||||
|
||||
def handle(conn, _opts), do: conn
|
||||
|
||||
defp do_action(%{"action" => "sign_in"} = attrs, resource),
|
||||
do: PasswordAuthentication.sign_in_action(resource, attrs)
|
||||
|
||||
defp do_action(%{"action" => "register"} = attrs, resource),
|
||||
do: PasswordAuthentication.register_action(resource, attrs)
|
||||
|
||||
defp do_action(_attrs, _resource), do: {:error, "No action provided"}
|
||||
end
|
|
@ -1,215 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.Transformer do
|
||||
@moduledoc """
|
||||
The PasswordAuthentication Authentication transformer.
|
||||
|
||||
Scans the resource and checks that all the fields and actions needed are
|
||||
present.
|
||||
|
||||
## What it's looking for.
|
||||
|
||||
In order for password authentication to work we need a few basic things to be present on the
|
||||
resource, but we _can_ generate almost everything we need, even if we do
|
||||
generate some actions, etc, we still must validate them because we want to
|
||||
allow the user to be able to overwrite as much as possible.
|
||||
|
||||
You can manually implement as much (or as little) of these as you wish.
|
||||
|
||||
Here's a (simplified) list of what it's validating:
|
||||
|
||||
* The main `AshAuthentication` extension is present on the resource.
|
||||
* There is an identity field configured (either by the user or by default) and
|
||||
that a writable attribute with the same name of the appropriate type exists.
|
||||
* There is a hashed password field configured (either by the user or by
|
||||
default) and that a writable attribute with the same name of the appropriate
|
||||
type exists.
|
||||
* That the configured hash provider actually implements the
|
||||
`AshAuthentication.HashProvider` behaviour.
|
||||
* That there is a read action called `sign_in` (or other name based on
|
||||
configuration) and that it has the following properties:
|
||||
- it takes an argument of the same name and type as the configured identity
|
||||
field.
|
||||
- it takes an argument of the same name and type as the configured password
|
||||
field.
|
||||
- it has the `PasswordAuthentication.SignInPreparation` preparation present.
|
||||
* That there is a create action called `register` (or other name based on
|
||||
configuration) and that it has the following properties:
|
||||
- it takes an argument of the same name and type as the configured identity field.
|
||||
- it takes an argument of the same name and type as the configured password field.
|
||||
- it takes an argument of the same name and type as the configured password confirmation field if confirmation is enabled.
|
||||
- it has the `PasswordAuthentication.HashPasswordChange` change present.
|
||||
- it has the `GenerateTokenChange` change present.
|
||||
- it has the `PasswordAuthentication.PasswordConfirmationValidation` validation present.
|
||||
|
||||
## Future improvements.
|
||||
|
||||
* Allow default constraints on password fields to be configurable.
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
|
||||
alias AshAuthentication.PasswordAuthentication.{
|
||||
HashPasswordChange,
|
||||
Info,
|
||||
PasswordConfirmationValidation,
|
||||
SignInPreparation
|
||||
}
|
||||
|
||||
alias Ash.{Resource, Type}
|
||||
alias AshAuthentication.GenerateTokenChange
|
||||
alias Spark.Dsl.Transformer
|
||||
import AshAuthentication.PasswordAuthentication.UserValidations
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec transform(map) ::
|
||||
:ok
|
||||
| {:ok, map()}
|
||||
| {:error, term()}
|
||||
| {:warn, map(), String.t() | [String.t()]}
|
||||
| :halt
|
||||
def transform(dsl_state) do
|
||||
with :ok <- validate_extension(dsl_state, AshAuthentication),
|
||||
{:ok, dsl_state} <- validate_identity_field(dsl_state),
|
||||
{:ok, dsl_state} <- validate_hashed_password_field(dsl_state),
|
||||
{:ok, register_action_name} <-
|
||||
Info.password_authentication_register_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
register_action_name,
|
||||
&build_register_action(&1, register_action_name)
|
||||
),
|
||||
{:ok, dsl_state} <- validate_register_action(dsl_state),
|
||||
{:ok, sign_in_action_name} <-
|
||||
Info.password_authentication_sign_in_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
sign_in_action_name,
|
||||
&build_sign_in_action(&1, sign_in_action_name)
|
||||
),
|
||||
{:ok, dsl_state} <- validate_sign_in_action(dsl_state),
|
||||
:ok <- validate_hash_provider(dsl_state) do
|
||||
authentication =
|
||||
Transformer.get_persisted(dsl_state, :authentication)
|
||||
|> Map.update(
|
||||
:providers,
|
||||
[AshAuthentication.PasswordAuthentication],
|
||||
&[AshAuthentication.PasswordAuthentication | &1]
|
||||
)
|
||||
|
||||
dsl_state =
|
||||
dsl_state
|
||||
|> Transformer.persist(:authentication, authentication)
|
||||
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
end
|
||||
|
||||
@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
|
||||
|
||||
defp build_register_action(dsl_state, action_name) do
|
||||
with {:ok, hashed_password_field} <-
|
||||
Info.password_authentication_hashed_password_field(dsl_state),
|
||||
{:ok, password_field} <- Info.password_authentication_password_field(dsl_state),
|
||||
{:ok, confirm_field} <-
|
||||
Info.password_authentication_password_confirmation_field(dsl_state),
|
||||
confirmation_required? <- Info.password_authentication_confirmation_required?(dsl_state) do
|
||||
password_opts = [
|
||||
type: Type.String,
|
||||
allow_nil?: false,
|
||||
constraints: [min_length: 8],
|
||||
sensitive?: true
|
||||
]
|
||||
|
||||
arguments =
|
||||
[
|
||||
Transformer.build_entity!(
|
||||
Resource.Dsl,
|
||||
[:actions, :create],
|
||||
:argument,
|
||||
Keyword.put(password_opts, :name, password_field)
|
||||
)
|
||||
]
|
||||
|> maybe_append(
|
||||
confirmation_required?,
|
||||
Transformer.build_entity!(
|
||||
Resource.Dsl,
|
||||
[:actions, :create],
|
||||
:argument,
|
||||
Keyword.put(password_opts, :name, confirm_field)
|
||||
)
|
||||
)
|
||||
|
||||
changes =
|
||||
[]
|
||||
|> maybe_append(
|
||||
confirmation_required?,
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :validate,
|
||||
validation: PasswordConfirmationValidation
|
||||
)
|
||||
)
|
||||
|> Enum.concat([
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
|
||||
change: HashPasswordChange
|
||||
),
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
|
||||
change: GenerateTokenChange
|
||||
)
|
||||
])
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :create,
|
||||
name: action_name,
|
||||
arguments: arguments,
|
||||
changes: changes,
|
||||
allow_nil_input: [hashed_password_field]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_sign_in_action(dsl_state, action_name) do
|
||||
with {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state),
|
||||
{:ok, password_field} <- Info.password_authentication_password_field(dsl_state) do
|
||||
identity_attribute = Resource.Info.attribute(dsl_state, identity_field)
|
||||
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||
name: identity_field,
|
||||
type: identity_attribute.type,
|
||||
allow_nil?: false
|
||||
),
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||
name: password_field,
|
||||
type: Type.String,
|
||||
allow_nil?: false,
|
||||
sensitive?: true
|
||||
)
|
||||
]
|
||||
|
||||
preparations = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
|
||||
preparation: SignInPreparation
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: action_name,
|
||||
arguments: arguments,
|
||||
preparations: preparations,
|
||||
get?: true
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,196 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.UserValidations do
|
||||
@moduledoc """
|
||||
Provides validations for the "user" resource.
|
||||
|
||||
See the module docs for `AshAuthentication.PasswordAuthentication.Transformer`
|
||||
for more information.
|
||||
"""
|
||||
alias Ash.Resource.Actions
|
||||
alias AshAuthentication.{GenerateTokenChange, HashProvider}
|
||||
|
||||
alias AshAuthentication.PasswordAuthentication.{
|
||||
HashPasswordChange,
|
||||
Info,
|
||||
PasswordConfirmationValidation,
|
||||
SignInPreparation
|
||||
}
|
||||
|
||||
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
|
||||
import AshAuthentication.Validations
|
||||
import AshAuthentication.Validations.Action
|
||||
import AshAuthentication.Validations.Attribute
|
||||
|
||||
@doc """
|
||||
Validate that the configured hash provider implements the `HashProvider`
|
||||
behaviour.
|
||||
"""
|
||||
@spec validate_hash_provider(Dsl.t()) :: :ok | {:error, Exception.t()}
|
||||
def validate_hash_provider(dsl_state) do
|
||||
case Info.password_authentication_hash_provider(dsl_state) do
|
||||
{:ok, hash_provider} ->
|
||||
validate_module_implements_behaviour(hash_provider, HashProvider)
|
||||
|
||||
:error ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:password_authentication, :hash_provider],
|
||||
message: "A hash provider must be set in your password authentication resource"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates information about the sign in action.
|
||||
"""
|
||||
@spec validate_sign_in_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
|
||||
def validate_sign_in_action(dsl_state) do
|
||||
with {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state),
|
||||
{:ok, password_field} <- Info.password_authentication_password_field(dsl_state),
|
||||
{:ok, action_name} <- Info.password_authentication_sign_in_action_name(dsl_state),
|
||||
{:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
:ok <- validate_identity_argument(dsl_state, action, identity_field),
|
||||
:ok <- validate_password_argument(action, password_field),
|
||||
:ok <- validate_action_has_preparation(action, SignInPreparation) do
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates information about the register action.
|
||||
"""
|
||||
@spec validate_register_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
|
||||
def validate_register_action(dsl_state) do
|
||||
with {:ok, password_field} <- Info.password_authentication_password_field(dsl_state),
|
||||
{:ok, password_confirmation_field} <-
|
||||
Info.password_authentication_password_confirmation_field(dsl_state),
|
||||
{:ok, hashed_password_field} <-
|
||||
Info.password_authentication_hashed_password_field(dsl_state),
|
||||
confirmation_required? <- Info.password_authentication_confirmation_required?(dsl_state),
|
||||
{:ok, action_name} <- Info.password_authentication_register_action_name(dsl_state),
|
||||
{:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
:ok <- validate_allow_nil_input(action, hashed_password_field),
|
||||
:ok <- validate_password_argument(action, password_field),
|
||||
:ok <-
|
||||
validate_password_confirmation_argument(
|
||||
action,
|
||||
password_confirmation_field,
|
||||
confirmation_required?
|
||||
),
|
||||
:ok <- validate_action_has_change(action, HashPasswordChange),
|
||||
:ok <- validate_action_has_change(action, GenerateTokenChange),
|
||||
:ok <-
|
||||
validate_action_has_validation(
|
||||
action,
|
||||
PasswordConfirmationValidation,
|
||||
confirmation_required?
|
||||
) do
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validate that the action allows nil input for the provided field.
|
||||
"""
|
||||
@spec validate_allow_nil_input(Actions.action(), atom) :: :ok | {:error, Exception.t()}
|
||||
def validate_allow_nil_input(action, field) do
|
||||
allowed_nil_fields = Map.get(action, :allow_nil_input, [])
|
||||
|
||||
if field in allowed_nil_fields do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :allow_nil_input],
|
||||
message:
|
||||
"Expected the action `#{inspect(action.name)}` to allow nil input for the field `#{inspect(field)}`"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Optionally validates that the action has a validation.
|
||||
"""
|
||||
@spec validate_action_has_validation(Actions.action(), module, really? :: boolean) ::
|
||||
:ok | {:error, Exception.t()}
|
||||
def validate_action_has_validation(_, _, false), do: :ok
|
||||
|
||||
def validate_action_has_validation(action, validation, _),
|
||||
do: validate_action_has_validation(action, validation)
|
||||
|
||||
@doc """
|
||||
Validate the identity argument.
|
||||
"""
|
||||
@spec validate_identity_argument(Dsl.t(), Actions.action(), atom) ::
|
||||
:ok | {:error, Exception.t()}
|
||||
def validate_identity_argument(dsl_state, action, identity_field) do
|
||||
identity_attribute = Ash.Resource.Info.attribute(dsl_state, identity_field)
|
||||
validate_action_argument_option(action, identity_field, :type, [identity_attribute.type])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validate the password argument.
|
||||
"""
|
||||
@spec validate_password_argument(Actions.action(), atom) :: :ok | {:error, Exception.t()}
|
||||
def validate_password_argument(action, password_field) do
|
||||
with :ok <- validate_action_argument_option(action, password_field, :type, [Ash.Type.String]) do
|
||||
validate_action_argument_option(action, password_field, :sensitive?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Optionally validates the password confirmation argument.
|
||||
"""
|
||||
@spec validate_password_confirmation_argument(Actions.action(), atom, really? :: boolean) ::
|
||||
:ok | {:error, Exception.t()}
|
||||
def validate_password_confirmation_argument(_, _, false), do: :ok
|
||||
|
||||
def validate_password_confirmation_argument(action, confirm_field, _),
|
||||
do: validate_password_argument(action, confirm_field)
|
||||
|
||||
@doc """
|
||||
Validate the identity field in the user resource.
|
||||
"""
|
||||
@spec validate_identity_field(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
|
||||
def validate_identity_field(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, identity_field),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]),
|
||||
:ok <- validate_attribute_unique_constraint(dsl_state, [identity_field], resource) do
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validate the hashed password field on the user resource.
|
||||
"""
|
||||
@spec validate_hashed_password_field(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
|
||||
def validate_hashed_password_field(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, hashed_password_field} <- identity_option(dsl_state, :hashed_password_field),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, hashed_password_field),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :sensitive?, [true]) do
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp identity_option(dsl_state, option) do
|
||||
case Transformer.get_option(dsl_state, [:password_authentication], option) do
|
||||
nil -> {:error, {:unknown_option, option}}
|
||||
value -> {:ok, value}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_module_implements_behaviour(module, behaviour) do
|
||||
if Spark.implements_behaviour?(module, behaviour),
|
||||
do: :ok,
|
||||
else:
|
||||
{:error,
|
||||
"Expected `#{inspect(module)}` to implement the `#{inspect(behaviour)}` behaviour"}
|
||||
rescue
|
||||
_ ->
|
||||
{:error, "Expected `#{inspect(module)}` to implement the `#{inspect(behaviour)}` behaviour"}
|
||||
end
|
||||
end
|
|
@ -1,212 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordReset do
|
||||
@default_lifetime_days 3
|
||||
|
||||
@dsl [
|
||||
%Spark.Dsl.Section{
|
||||
name: :password_reset,
|
||||
describe: "Configure password reset behaviour",
|
||||
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.
|
||||
""",
|
||||
default: :request_password_reset
|
||||
],
|
||||
password_reset_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name to use for the action which actually resets the user's password.
|
||||
""",
|
||||
default: :reset_password
|
||||
],
|
||||
sender: [
|
||||
type:
|
||||
{:spark_function_behaviour, AshAuthentication.Sender,
|
||||
{AshAuthentication.SenderFunction, 2}},
|
||||
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
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@moduledoc """
|
||||
Allow users to reset their passwords.
|
||||
|
||||
This extension provides a mechanism to allow users to reset their password as
|
||||
in your typical "forgotten password" flow.
|
||||
|
||||
This requires the `AshAuthentication.PasswordAuthentication` extension to be
|
||||
present, in order to be able to update the password.
|
||||
|
||||
## Senders
|
||||
|
||||
You can set the DSL's `sender` key to be either a three-arity anonymous
|
||||
function or a module which implements the `AshAuthentication.Sender`
|
||||
behaviour. This callback can be used to send password reset instructions to
|
||||
the user via the system of your choice. See `AshAuthentication.Sender` for
|
||||
more information.
|
||||
|
||||
## Usage
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.Users do
|
||||
use Ash.Resource,
|
||||
extensions: [
|
||||
AshAuthentication.PasswordAuthentication,
|
||||
AshAuthentication.PasswordReset
|
||||
]
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :email, :ci_string, allow_nil?: false
|
||||
end
|
||||
|
||||
password_reset do
|
||||
token_lifetime 24
|
||||
sender MyApp.ResetRequestSender
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
* `request` - send the identity field nested below the subject name (eg
|
||||
`%{"user" => %{"email" => "marty@mcfly.me"}}`). If the resource supports
|
||||
password resets then the success callback will be called with a `nil` user
|
||||
and token regardless of whether the user could be found. If the user is
|
||||
found then the `sender` will be called.
|
||||
* `callback` - attempt to perform a password reset. Should be called with the
|
||||
reset token, password and password confirmation if confirmation is enabled,
|
||||
nested below the subject name (eg `%{"user" => %{"reset_token" => "abc123",
|
||||
"password" => "back to 1985", "password_confirmation" => "back to 1975"}}`).
|
||||
If the password was successfully changed then the relevant user will be
|
||||
returned to the `success` callback.
|
||||
|
||||
## DSL Documentation
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index(@dsl)}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc(@dsl)}
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: @dsl,
|
||||
transformers: [AshAuthentication.PasswordReset.Transformer]
|
||||
|
||||
use AshAuthentication.Provider
|
||||
|
||||
alias Ash.{Changeset, Query, Resource}
|
||||
alias AshAuthentication.{Jwt, PasswordReset}
|
||||
|
||||
@doc """
|
||||
Request a password reset for a user.
|
||||
|
||||
If the record supports password resets then the reset token will be generated and sent.
|
||||
|
||||
## Example
|
||||
|
||||
iex> request_password_reset(MyApp.Accounts.User, %{"email" => "marty@mcfly.me"})
|
||||
:ok
|
||||
"""
|
||||
@spec request_password_reset(Resource.t(), params) :: :ok | {:error, any}
|
||||
when params: %{required(String.t()) => String.t()}
|
||||
def request_password_reset(resource, params) do
|
||||
with true <- enabled?(resource),
|
||||
{:ok, action} <- PasswordReset.Info.request_password_reset_action_name(resource),
|
||||
{:ok, api} <- AshAuthentication.Info.authentication_api(resource),
|
||||
query <- Query.for_read(resource, action, params),
|
||||
{:ok, _} <- api.read(query) do
|
||||
:ok
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
_ -> {:error, "Password resets not supported by resource `#{inspect(resource)}`"}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reset a user's password.
|
||||
|
||||
Given a reset token, password and _maybe_ password confirmation, validate and
|
||||
change the user's password.
|
||||
|
||||
## Example
|
||||
|
||||
iex> reset_password(MyApp.Accounts.User, params)
|
||||
{:ok, %MyApp.Accounts.User{}}
|
||||
"""
|
||||
@spec reset_password(Resource.t(), params) :: {:ok, Resource.record()} | {:error, Changeset.t()}
|
||||
when params: %{required(String.t()) => String.t()}
|
||||
def reset_password(resource, params) do
|
||||
with true <- enabled?(resource),
|
||||
{:ok, token} <- Map.fetch(params, "reset_token"),
|
||||
{:ok, %{"sub" => subject}, config} <- Jwt.verify(token, resource),
|
||||
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config),
|
||||
{:ok, action} <- PasswordReset.Info.password_reset_action_name(config.resource),
|
||||
{:ok, api} <- AshAuthentication.Info.authentication_api(resource) do
|
||||
user
|
||||
|> Changeset.for_update(action, params)
|
||||
|> api.update()
|
||||
else
|
||||
false -> {:error, "Password resets not supported by resource `#{inspect(resource)}`"}
|
||||
:error -> {:error, "Invalid reset token"}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generate a reset token for a user.
|
||||
"""
|
||||
@spec reset_token_for(Resource.record()) :: {:ok, String.t()} | :error
|
||||
def reset_token_for(user) do
|
||||
resource = user.__struct__
|
||||
|
||||
with true <- enabled?(resource),
|
||||
{:ok, lifetime} <- PasswordReset.Info.token_lifetime(resource),
|
||||
{:ok, action} <- PasswordReset.Info.password_reset_action_name(resource),
|
||||
{:ok, token, _claims} <-
|
||||
Jwt.token_for_record(user, %{"act" => action}, token_lifetime: lifetime) do
|
||||
{:ok, token}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handle the request phase.
|
||||
|
||||
Handles a HTTP request for a password reset.
|
||||
"""
|
||||
@impl true
|
||||
defdelegate request_plug(conn, any), to: PasswordReset.Plug, as: :request
|
||||
|
||||
@doc """
|
||||
Handle the callback phase.
|
||||
|
||||
Handles a HTTP password change request.
|
||||
"""
|
||||
@impl true
|
||||
defdelegate callback_plug(conn, any), to: PasswordReset.Plug, as: :callback
|
||||
end
|
|
@ -1,94 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordReset.Html do
|
||||
@moduledoc """
|
||||
Renders a very basic form for password resetting.
|
||||
|
||||
These are mainly used for testing, and you should instead write your own or
|
||||
use the widgets in `ash_authentication_phoenix`.
|
||||
"""
|
||||
|
||||
require EEx
|
||||
alias AshAuthentication.{PasswordAuthentication, PasswordReset}
|
||||
|
||||
EEx.function_from_string(
|
||||
:defp,
|
||||
:render_request,
|
||||
~s"""
|
||||
<form method="<%= @method %>" action="<%= @action %>">
|
||||
<fieldset>
|
||||
<%= if @legend do %><legend><%= @legend %></legend><% end %>
|
||||
<input type="text" name="<%= @subject_name %>[<%= @identity_field %>]" placeholder="<%= @identity_field %>" />
|
||||
<br />
|
||||
<input type="submit" value="Request password reset" />
|
||||
</fieldset>
|
||||
</form>
|
||||
""",
|
||||
[:assigns]
|
||||
)
|
||||
|
||||
EEx.function_from_string(
|
||||
:defp,
|
||||
:render_reset,
|
||||
~s"""
|
||||
<form method="<%= @method %>" action="<%= @action %>">
|
||||
<fieldset>
|
||||
<%= if @legend do %><legend><%= @legend %></legend><% end %>
|
||||
<input type="token" name="<%= @subject_name %>[reset_token]" placeholder="Reset token" />
|
||||
<br />
|
||||
<input type="password" name="<%= @subject_name %>[<%= @password_field %>]" placeholder="Password" />
|
||||
<br />
|
||||
<%= if @confirmation_required? do %>
|
||||
<input type="password" name="<%= @subject_name %>[<%= @password_confirmation_field %>]" placeholder="Password confirmation" />
|
||||
<br />
|
||||
<% end %>
|
||||
<input type="submit" value="Reset password" />
|
||||
</fieldset>
|
||||
</form>
|
||||
""",
|
||||
[:assigns]
|
||||
)
|
||||
|
||||
@defaults [method: "POST", legend: nil]
|
||||
|
||||
@type options :: [method_option | action_option]
|
||||
|
||||
@typedoc """
|
||||
The HTTP method used to submit the form.
|
||||
|
||||
Defaults to `#{inspect(Keyword.get(@defaults, :method))}`.
|
||||
"""
|
||||
@type method_option :: {:method, String.t()}
|
||||
|
||||
@typedoc """
|
||||
The path/URL to which the form should be submitted.
|
||||
"""
|
||||
@type action_option :: {:action, String.t()}
|
||||
|
||||
@doc """
|
||||
Render a reset request.
|
||||
"""
|
||||
@spec request(module, options) :: String.t()
|
||||
def request(resource, options) do
|
||||
resource
|
||||
|> build_assigns(Keyword.put(options, :legend, "Request password reset"))
|
||||
|> render_request()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render a reset form
|
||||
"""
|
||||
@spec callback(module, options) :: String.t()
|
||||
def callback(resource, options) do
|
||||
resource
|
||||
|> build_assigns(Keyword.put(options, :legend, "Reset password"))
|
||||
|> render_reset()
|
||||
end
|
||||
|
||||
defp build_assigns(resource, options) do
|
||||
@defaults
|
||||
|> Keyword.merge(options)
|
||||
|> Map.new()
|
||||
|> Map.merge(PasswordAuthentication.Info.password_authentication_options(resource))
|
||||
|> Map.merge(PasswordReset.Info.options(resource))
|
||||
|> Map.merge(AshAuthentication.Info.authentication_options(resource))
|
||||
end
|
||||
end
|
|
@ -1,9 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordReset.Info do
|
||||
@moduledoc """
|
||||
Generated configuration functions based on a resource's DSL configuration.
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication.PasswordReset,
|
||||
sections: [:password_reset]
|
||||
end
|
|
@ -1,45 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordReset.Plug do
|
||||
@moduledoc """
|
||||
Handlers for incoming HTTP requests.
|
||||
"""
|
||||
|
||||
import AshAuthentication.Plug.Helpers, only: [private_store: 2]
|
||||
alias AshAuthentication.PasswordReset
|
||||
alias Plug.Conn
|
||||
|
||||
@doc """
|
||||
Handle an inbound password reset request.
|
||||
"""
|
||||
@spec request(Conn.t(), any) :: Conn.t()
|
||||
def request(%{params: params, private: %{authenticator: config}} = conn, _opts) do
|
||||
params =
|
||||
params
|
||||
|> Map.get(to_string(config.subject_name), %{})
|
||||
|
||||
case PasswordReset.request_password_reset(config.resource, params) do
|
||||
:ok ->
|
||||
private_store(conn, {:success, nil})
|
||||
|
||||
{:error, reason} ->
|
||||
private_store(conn, {:failure, reason})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handle an inbound password reset.
|
||||
"""
|
||||
@spec callback(Conn.t(), any) :: Conn.t()
|
||||
def callback(%{params: params, private: %{authenticator: config}} = conn, _opts) do
|
||||
params =
|
||||
params
|
||||
|> Map.get(to_string(config.subject_name), %{})
|
||||
|
||||
case PasswordReset.reset_password(config.resource, params) do
|
||||
{:ok, user} when is_struct(user, config.resource) ->
|
||||
private_store(conn, {:success, user})
|
||||
|
||||
{:error, reason} ->
|
||||
private_store(conn, {:failure, reason})
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,45 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordReset.RequestPasswordResetPreparation do
|
||||
@moduledoc """
|
||||
Prepare a query for a password reset request.
|
||||
|
||||
This preparation performs three jobs, one before the query executes and two
|
||||
after.
|
||||
|
||||
Firstly, it constraints the query to match the identity field passed to the
|
||||
action.
|
||||
|
||||
Secondly, if there is a user returned by the query, then generate a reset
|
||||
token and publish a notification. Always returns an empty result.
|
||||
"""
|
||||
use Ash.Resource.Preparation
|
||||
alias Ash.{Query, Resource.Preparation}
|
||||
alias AshAuthentication.{PasswordAuthentication, PasswordReset}
|
||||
require Ash.Query
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
|
||||
def prepare(query, _opts, _context) do
|
||||
{:ok, identity_field} =
|
||||
PasswordAuthentication.Info.password_authentication_identity_field(query.resource)
|
||||
|
||||
{:ok, {sender, send_opts}} = PasswordReset.Info.sender(query.resource)
|
||||
|
||||
identity = Query.get_argument(query, identity_field)
|
||||
|
||||
query
|
||||
|> Query.filter(ref(^identity_field) == ^identity)
|
||||
|> Query.after_action(fn
|
||||
_query, [user] ->
|
||||
case PasswordReset.reset_token_for(user) do
|
||||
{:ok, token} -> sender.send(user, token, send_opts)
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
{:ok, []}
|
||||
|
||||
_, _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -1,217 +0,0 @@
|
|||
defmodule AshAuthentication.PasswordReset.Transformer do
|
||||
@moduledoc """
|
||||
The PasswordReset transformer.
|
||||
|
||||
Scans the resource and checks that all the fields and actions needed are
|
||||
present.
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
|
||||
alias AshAuthentication.PasswordReset.{
|
||||
Info,
|
||||
RequestPasswordResetPreparation,
|
||||
ResetTokenValidation
|
||||
}
|
||||
|
||||
alias Ash.{Resource, Type}
|
||||
alias AshAuthentication.PasswordAuthentication, as: PA
|
||||
alias AshAuthentication.{GenerateTokenChange, Sender}
|
||||
alias Spark.Dsl.Transformer
|
||||
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
import AshAuthentication.Validations.Action
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec transform(map) ::
|
||||
:ok
|
||||
| {:ok, map()}
|
||||
| {:error, term()}
|
||||
| {:warn, map(), String.t() | [String.t()]}
|
||||
| :halt
|
||||
def transform(dsl_state) do
|
||||
with :ok <- validate_extension(dsl_state, AshAuthentication),
|
||||
:ok <- validate_extension(dsl_state, PA),
|
||||
:ok <- validate_token_generation_enabled(dsl_state),
|
||||
{:ok, {sender, _opts}} <- Info.sender(dsl_state),
|
||||
:ok <- validate_behaviour(sender, Sender),
|
||||
{:ok, request_action_name} <- Info.request_password_reset_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
request_action_name,
|
||||
&build_request_action(&1, request_action_name)
|
||||
),
|
||||
:ok <- validate_request_action(dsl_state, request_action_name),
|
||||
{:ok, change_action_name} <- Info.password_reset_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
change_action_name,
|
||||
&build_change_action(&1, change_action_name)
|
||||
),
|
||||
:ok <- validate_change_action(dsl_state, change_action_name) do
|
||||
authentication =
|
||||
Transformer.get_persisted(dsl_state, :authentication)
|
||||
|> Map.update(
|
||||
:providers,
|
||||
[AshAuthentication.PasswordReset],
|
||||
&[AshAuthentication.PasswordReset | &1]
|
||||
)
|
||||
|
||||
dsl_state =
|
||||
dsl_state
|
||||
|> Transformer.persist(:authentication, authentication)
|
||||
|
||||
{:ok, dsl_state}
|
||||
else
|
||||
:error -> {:error, "Configuration error"}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec after?(module) :: boolean
|
||||
def after?(AshAuthentication.Transformer), do: true
|
||||
def after?(PA.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
|
||||
|
||||
defp build_request_action(dsl_state, action_name) do
|
||||
with {:ok, identity_field} <- PA.Info.password_authentication_identity_field(dsl_state) do
|
||||
identity_attribute = Resource.Info.attribute(dsl_state, identity_field)
|
||||
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||
name: identity_field,
|
||||
type: identity_attribute.type,
|
||||
allow_nil?: false
|
||||
)
|
||||
]
|
||||
|
||||
preparations = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
|
||||
preparation: RequestPasswordResetPreparation
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: action_name,
|
||||
arguments: arguments,
|
||||
preparations: preparations
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_change_action(dsl_state, action_name) do
|
||||
with {:ok, password_field} <- PA.Info.password_authentication_password_field(dsl_state),
|
||||
{:ok, confirm_field} <-
|
||||
PA.Info.password_authentication_password_confirmation_field(dsl_state),
|
||||
confirmation_required? <-
|
||||
PA.Info.password_authentication_confirmation_required?(dsl_state) do
|
||||
password_opts = [
|
||||
type: Type.String,
|
||||
allow_nil?: false,
|
||||
constraints: [min_length: 8],
|
||||
sensitive?: true
|
||||
]
|
||||
|
||||
arguments =
|
||||
[
|
||||
Transformer.build_entity!(
|
||||
Resource.Dsl,
|
||||
[:actions, :update],
|
||||
:argument,
|
||||
name: :reset_token,
|
||||
type: Type.String,
|
||||
sensitive?: true
|
||||
),
|
||||
Transformer.build_entity!(
|
||||
Resource.Dsl,
|
||||
[:actions, :update],
|
||||
:argument,
|
||||
Keyword.put(password_opts, :name, password_field)
|
||||
)
|
||||
]
|
||||
|> maybe_append(
|
||||
confirmation_required?,
|
||||
Transformer.build_entity!(
|
||||
Resource.Dsl,
|
||||
[:actions, :update],
|
||||
:argument,
|
||||
Keyword.put(password_opts, :name, confirm_field)
|
||||
)
|
||||
)
|
||||
|
||||
changes =
|
||||
[
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate,
|
||||
validation: ResetTokenValidation
|
||||
)
|
||||
]
|
||||
|> maybe_append(
|
||||
confirmation_required?,
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate,
|
||||
validation: PA.PasswordConfirmationValidation
|
||||
)
|
||||
)
|
||||
|> Enum.concat([
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
|
||||
change: PA.HashPasswordChange
|
||||
),
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
|
||||
change: GenerateTokenChange
|
||||
)
|
||||
])
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :update,
|
||||
name: action_name,
|
||||
arguments: arguments,
|
||||
changes: changes,
|
||||
accept: []
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_request_action(dsl_state, action_name) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
{:ok, identity_field} <- PA.Info.password_authentication_identity_field(dsl_state),
|
||||
:ok <- PA.UserValidations.validate_identity_argument(dsl_state, action, identity_field) do
|
||||
validate_action_has_preparation(action, RequestPasswordResetPreparation)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_change_action(dsl_state, action_name) do
|
||||
with {:ok, password_field} <- PA.Info.password_authentication_password_field(dsl_state),
|
||||
{:ok, password_confirmation_field} <-
|
||||
PA.Info.password_authentication_password_confirmation_field(dsl_state),
|
||||
confirmation_required? <-
|
||||
PA.Info.password_authentication_confirmation_required?(dsl_state),
|
||||
{:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
:ok <- validate_action_has_validation(action, ResetTokenValidation),
|
||||
:ok <- validate_action_has_change(action, PA.HashPasswordChange),
|
||||
:ok <- PA.UserValidations.validate_password_argument(action, password_field),
|
||||
:ok <-
|
||||
PA.UserValidations.validate_password_confirmation_argument(
|
||||
action,
|
||||
password_confirmation_field,
|
||||
confirmation_required?
|
||||
),
|
||||
:ok <-
|
||||
PA.UserValidations.validate_action_has_validation(
|
||||
action,
|
||||
PA.PasswordConfirmationValidation,
|
||||
confirmation_required?
|
||||
) do
|
||||
validate_action_has_change(action, GenerateTokenChange)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -8,13 +8,13 @@ defmodule AshAuthentication.Plug do
|
|||
defmodule MyAppWeb.AuthPlug do
|
||||
use AshAuthentication.Plug, otp_app: :my_app
|
||||
|
||||
def handle_success(conn, user, _token) do
|
||||
def handle_success(conn, _activity, user, _token) do
|
||||
conn
|
||||
|> store_in_session(user)
|
||||
|> send_resp(200, "Welcome back #{user.name}")
|
||||
end
|
||||
|
||||
def handle_failure(conn, reason) do
|
||||
def handle_failure(conn, _activity, reason) do
|
||||
conn
|
||||
|> send_resp(401, "Better luck next time")
|
||||
end
|
||||
|
@ -69,21 +69,17 @@ defmodule AshAuthentication.Plug do
|
|||
do useful things like session and query param fetching.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Error, Resource}
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.Plug.{Defaults, Helpers, Macros}
|
||||
alias Plug.Conn
|
||||
require Macros
|
||||
|
||||
@type authenticator_config :: %{
|
||||
api: module,
|
||||
provider: module,
|
||||
resource: module,
|
||||
subject: atom
|
||||
}
|
||||
@type activity :: {atom, atom}
|
||||
@type token :: String.t()
|
||||
|
||||
@doc """
|
||||
When authentication has been succesful, this callback will be called with the
|
||||
conn, the authenticated resource and a token.
|
||||
conn, the successful activity, the authenticated resource and a token.
|
||||
|
||||
This allows you to choose what action to take as appropriate for your
|
||||
application.
|
||||
|
@ -92,19 +88,21 @@ defmodule AshAuthentication.Plug do
|
|||
"Access granted" message to the user. You almost definitely want to override
|
||||
this behaviour.
|
||||
"""
|
||||
@callback handle_success(Conn.t(), Resource.record(), token :: String.t()) :: Conn.t()
|
||||
@callback handle_success(Conn.t(), activity, Resource.record() | nil, token | nil) :: Conn.t()
|
||||
|
||||
@doc """
|
||||
When there is any failure during authentication this callback is called.
|
||||
|
||||
Note that this includes not just authentication failures, but even simple
|
||||
404s.
|
||||
Note that this includes not just authentication failures but potentially
|
||||
route-not-found errors also.
|
||||
|
||||
The default implementation simply returns a 401 status with the message
|
||||
"Access denied". You almost definitely want to override this.
|
||||
"""
|
||||
@callback handle_failure(Conn.t(), nil | Changeset.t() | Error.t()) :: Conn.t()
|
||||
@callback handle_failure(Conn.t(), activity, any) :: Conn.t()
|
||||
|
||||
@doc false
|
||||
@spec __using__(keyword) :: Macro.t()
|
||||
defmacro __using__(opts) do
|
||||
otp_app =
|
||||
opts
|
||||
|
@ -135,12 +133,12 @@ defmodule AshAuthentication.Plug do
|
|||
Macros.define_revoke_bearer_tokens(unquote(otp_app))
|
||||
|
||||
@impl true
|
||||
defdelegate handle_success(conn, user, token), to: Defaults
|
||||
defdelegate handle_success(conn, activity, user, token), to: Defaults
|
||||
|
||||
@impl true
|
||||
defdelegate handle_failure(conn, error), to: Defaults
|
||||
defdelegate handle_failure(conn, activity, error), to: Defaults
|
||||
|
||||
defoverridable handle_success: 3, handle_failure: 2
|
||||
defoverridable handle_success: 4, handle_failure: 3
|
||||
|
||||
@impl true
|
||||
defdelegate init(opts), to: Router
|
||||
|
|
|
@ -4,7 +4,7 @@ defmodule AshAuthentication.Plug.Defaults do
|
|||
`handle_failure/2` used in generated authentication plugs.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Error, Resource}
|
||||
alias Ash.Resource
|
||||
alias Plug.Conn
|
||||
import AshAuthentication.Plug.Helpers
|
||||
import Plug.Conn
|
||||
|
@ -15,9 +15,9 @@ defmodule AshAuthentication.Plug.Defaults do
|
|||
Calls `AshAuthentication.Plug.Helpers.store_in_session/2` then sends a
|
||||
basic 200 response.
|
||||
"""
|
||||
@spec handle_success(Conn.t(), Resource.record(), token :: String.t()) ::
|
||||
@spec handle_success(Conn.t(), {atom, atom}, Resource.record() | nil, String.t() | nil) ::
|
||||
Conn.t()
|
||||
def handle_success(conn, user, _token) do
|
||||
def handle_success(conn, _activity, user, _token) do
|
||||
conn
|
||||
|> store_in_session(user)
|
||||
|> send_resp(200, "Access granted")
|
||||
|
@ -28,8 +28,8 @@ defmodule AshAuthentication.Plug.Defaults do
|
|||
|
||||
Sends a very basic 401 response.
|
||||
"""
|
||||
@spec handle_failure(Conn.t(), nil | Changeset.t() | Error.t()) :: Conn.t()
|
||||
def handle_failure(conn, _) do
|
||||
@spec handle_failure(Conn.t(), {atom, atom}, any) :: Conn.t()
|
||||
def handle_failure(conn, _, _) do
|
||||
conn
|
||||
|> send_resp(401, "Access denied")
|
||||
end
|
||||
|
|
|
@ -4,9 +4,13 @@ defmodule AshAuthentication.Plug.Dispatcher do
|
|||
"""
|
||||
|
||||
@behaviour Plug
|
||||
alias AshAuthentication.Strategy
|
||||
alias Plug.Conn
|
||||
import AshAuthentication.Plug.Helpers, only: [get_authentication_result: 1]
|
||||
|
||||
@type config :: {:request | :callback, [AshAuthentication.Plug.authenticator_config()], module}
|
||||
@type config :: {atom, Strategy.t(), module} | module
|
||||
|
||||
@unsent ~w[unset set set_chunked set_file]a
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
|
@ -14,57 +18,44 @@ defmodule AshAuthentication.Plug.Dispatcher do
|
|||
def init([config]), do: config
|
||||
|
||||
@doc """
|
||||
Match the `subject_name` and `provider` of the incoming request to a provider and
|
||||
call the appropriate plug with the configuration.
|
||||
Send the request to the correct strategy and then return the result.
|
||||
"""
|
||||
@impl true
|
||||
@spec call(Conn.t(), config | any) :: Conn.t()
|
||||
def call(conn, {phase, routes, return_to}) do
|
||||
conn
|
||||
|> dispatch(phase, routes)
|
||||
|> return(return_to)
|
||||
end
|
||||
def call(conn, {phase, strategy, return_to}) do
|
||||
activity = {strategy.name, phase}
|
||||
|
||||
defp dispatch(
|
||||
%{params: %{"subject_name" => subject_name, "provider" => provider}} = conn,
|
||||
phase,
|
||||
routes
|
||||
) do
|
||||
case Map.get(routes, {subject_name, provider}) do
|
||||
config when is_map(config) ->
|
||||
conn = Conn.put_private(conn, :authenticator, config)
|
||||
|
||||
case phase do
|
||||
:request -> config.provider.request_plug(conn, [])
|
||||
:callback -> config.provider.callback_plug(conn, [])
|
||||
end
|
||||
|
||||
_ ->
|
||||
strategy
|
||||
|> Strategy.plug(phase, conn)
|
||||
|> get_authentication_result()
|
||||
|> case do
|
||||
{conn, _} when conn.state not in @unsent ->
|
||||
conn
|
||||
|
||||
{conn, :ok} ->
|
||||
return_to.handle_success(conn, activity, nil, nil)
|
||||
|
||||
{conn, {:ok, user}} when is_binary(user.__metadata__.token) ->
|
||||
return_to.handle_success(conn, activity, user, user.__metadata__.token)
|
||||
|
||||
{conn, {:ok, user}} ->
|
||||
return_to.handle_success(conn, activity, user, nil)
|
||||
|
||||
{conn, :error} ->
|
||||
return_to.handle_failure(conn, activity, nil)
|
||||
|
||||
{conn, {:error, reason}} ->
|
||||
return_to.handle_failure(conn, activity, reason)
|
||||
|
||||
conn when conn.state not in @unsent ->
|
||||
conn
|
||||
|
||||
conn ->
|
||||
return_to.handle_failure(conn, activity, :no_authentication_result)
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch(conn, _phase, _routes), do: conn
|
||||
|
||||
defp return(%{state: :sent} = conn, _return_to), do: conn
|
||||
|
||||
defp return(
|
||||
%{
|
||||
private: %{
|
||||
authentication_result: {:success, user},
|
||||
authenticator: %{resource: resource}
|
||||
}
|
||||
} = conn,
|
||||
return_to
|
||||
)
|
||||
when is_struct(user, resource),
|
||||
do: return_to.handle_success(conn, user, Map.get(user.__metadata__, :token))
|
||||
|
||||
defp return(%{private: %{authentication_result: {:success, nil}}} = conn, return_to),
|
||||
do: return_to.handle_success(conn, nil, nil)
|
||||
|
||||
defp return(%{private: %{authentication_result: {:failure, reason}}} = conn, return_to),
|
||||
do: return_to.handle_failure(conn, reason)
|
||||
|
||||
defp return(conn, return_to), do: return_to.handle_failure(conn, nil)
|
||||
def call(conn, return_to) do
|
||||
return_to.handle_failure(conn, {nil, nil}, :not_found)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,8 +13,8 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
@spec store_in_session(Conn.t(), Resource.record()) :: Conn.t()
|
||||
|
||||
def store_in_session(conn, user) when is_struct(user) do
|
||||
subject_name = AshAuthentication.Info.authentication_subject_name!(user.__struct__)
|
||||
subject = AshAuthentication.resource_to_subject(user)
|
||||
subject_name = Info.authentication_subject_name!(user.__struct__)
|
||||
subject = AshAuthentication.user_to_subject(user)
|
||||
|
||||
Conn.put_session(conn, subject_name, subject)
|
||||
end
|
||||
|
@ -26,19 +26,20 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
"""
|
||||
@spec load_subjects([AshAuthentication.subject()], module) :: map
|
||||
def load_subjects(subjects, otp_app) when is_list(subjects) do
|
||||
configurations =
|
||||
resources =
|
||||
otp_app
|
||||
|> AshAuthentication.authenticated_resources()
|
||||
|> Stream.map(&{to_string(&1.subject_name), &1})
|
||||
|> Stream.map(&{to_string(Info.authentication_subject_name!(&1)), &1})
|
||||
|> Map.new()
|
||||
|
||||
subjects
|
||||
|> Enum.reduce(%{}, fn subject, result ->
|
||||
subject = URI.parse(subject)
|
||||
|
||||
with {:ok, config} <- Map.fetch(configurations, subject.path),
|
||||
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config) do
|
||||
current_subject_name = current_subject_name(config.subject_name)
|
||||
with {:ok, resource} <- Map.fetch(resources, subject.path),
|
||||
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource),
|
||||
{:ok, subject_name} <- Info.authentication_subject_name(resource) do
|
||||
current_subject_name = current_subject_name(subject_name)
|
||||
|
||||
Map.put(result, current_subject_name, user)
|
||||
else
|
||||
|
@ -60,11 +61,12 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
def retrieve_from_session(conn, otp_app) do
|
||||
otp_app
|
||||
|> AshAuthentication.authenticated_resources()
|
||||
|> Enum.reduce(conn, fn config, conn ->
|
||||
current_subject_name = current_subject_name(config.subject_name)
|
||||
|> Stream.map(&{&1, Info.authentication_options(&1)})
|
||||
|> Enum.reduce(conn, fn {resource, options}, conn ->
|
||||
current_subject_name = current_subject_name(options.subject_name)
|
||||
|
||||
with subject when is_binary(subject) <- Conn.get_session(conn, config.subject_name),
|
||||
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config) do
|
||||
with subject when is_binary(subject) <- Conn.get_session(conn, options.subject_name),
|
||||
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do
|
||||
Conn.assign(conn, current_subject_name, user)
|
||||
else
|
||||
_ ->
|
||||
|
@ -89,9 +91,10 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
|> Stream.filter(&String.starts_with?(&1, "Bearer "))
|
||||
|> Stream.map(&String.replace_leading(&1, "Bearer ", ""))
|
||||
|> Enum.reduce(conn, fn token, conn ->
|
||||
with {:ok, %{"sub" => subject}, config} <- Jwt.verify(token, otp_app),
|
||||
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config),
|
||||
current_subject_name <- current_subject_name(config.subject_name) do
|
||||
with {:ok, %{"sub" => subject}, resource} <- Jwt.verify(token, otp_app),
|
||||
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource),
|
||||
{:ok, subject_name} <- Info.authentication_subject_name(resource),
|
||||
current_subject_name <- current_subject_name(subject_name) do
|
||||
conn
|
||||
|> Conn.assign(current_subject_name, user)
|
||||
else
|
||||
|
@ -112,8 +115,8 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
|> Stream.filter(&String.starts_with?(&1, "Bearer "))
|
||||
|> Stream.map(&String.replace_leading(&1, "Bearer ", ""))
|
||||
|> Enum.reduce(conn, fn token, conn ->
|
||||
with {:ok, config} <- Jwt.token_to_resource(token, otp_app),
|
||||
{:ok, revocation_resource} <- Info.tokens_revocation_resource(config.resource),
|
||||
with {:ok, resource} <- Jwt.token_to_resource(token, otp_app),
|
||||
{:ok, revocation_resource} <- Info.authentication_tokens_revocation_resource(resource),
|
||||
:ok <- TokenRevocation.revoke(revocation_resource, token) do
|
||||
conn
|
||||
else
|
||||
|
@ -170,17 +173,28 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
This is used by authentication plug handlers to store their result for passing
|
||||
back to the dispatcher.
|
||||
"""
|
||||
@spec private_store(Conn.t(), {:success, nil | Resource.record()} | {:failure, any}) :: Conn.t()
|
||||
@spec store_authentication_result(
|
||||
Conn.t(),
|
||||
:ok | {:ok, Resource.record()} | :error | {:error, any}
|
||||
) ::
|
||||
Conn.t()
|
||||
|
||||
def private_store(conn, {:success, nil}),
|
||||
do: Conn.put_private(conn, :authentication_result, {:success, nil})
|
||||
def store_authentication_result(conn, :ok),
|
||||
do: Conn.put_private(conn, :authentication_result, {:ok, nil})
|
||||
|
||||
def private_store(conn, {:success, record})
|
||||
when is_struct(record, conn.private.authenticator.resource),
|
||||
do: Conn.put_private(conn, :authentication_result, {:success, record})
|
||||
def store_authentication_result(conn, {:ok, record}),
|
||||
do: Conn.put_private(conn, :authentication_result, {:ok, record})
|
||||
|
||||
def private_store(conn, {:failure, reason}),
|
||||
do: Conn.put_private(conn, :authentication_result, {:failure, reason})
|
||||
def store_authentication_result(conn, :error),
|
||||
do: Conn.put_private(conn, :authentication_result, :error)
|
||||
|
||||
def store_authentication_result(conn, {:error, reason}),
|
||||
do: Conn.put_private(conn, :authentication_result, {:error, reason})
|
||||
|
||||
def get_authentication_result(%{private: %{authentication_result: result}} = conn),
|
||||
do: {conn, result}
|
||||
|
||||
def get_authentication_result(conn), do: conn
|
||||
|
||||
# Dyanamically generated atoms are generally frowned upon, but in this case
|
||||
# the `subject_name` is a statically configured atom, so should be fine.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
defmodule AshAuthentication.Plug.Macros do
|
||||
@moduledoc """
|
||||
Generators used within `AshAuthentication.Plug.__using_/1`.
|
||||
Generators used within `use AshAuthentication.Plug`.
|
||||
"""
|
||||
|
||||
alias Ash.Api
|
||||
|
|
|
@ -6,6 +6,8 @@ defmodule AshAuthentication.Plug.Router do
|
|||
Used internally by `AshAuthentication.Plug`.
|
||||
"""
|
||||
|
||||
alias AshAuthentication.{Info, Strategy}
|
||||
|
||||
@doc false
|
||||
@spec __using__(keyword) :: Macro.t()
|
||||
defmacro __using__(opts) do
|
||||
|
@ -19,47 +21,30 @@ defmodule AshAuthentication.Plug.Router do
|
|||
|> Keyword.fetch!(:return_to)
|
||||
|> Macro.expand_once(__CALLER__)
|
||||
|
||||
routes =
|
||||
otp_app
|
||||
|> AshAuthentication.authenticated_resources()
|
||||
|> Stream.flat_map(fn config ->
|
||||
subject_name =
|
||||
config.subject_name
|
||||
|> to_string()
|
||||
|
||||
config
|
||||
|> Map.get(:providers, [])
|
||||
|> Stream.map(fn provider ->
|
||||
config =
|
||||
config
|
||||
|> Map.delete(:providers)
|
||||
|> Map.put(:provider, provider)
|
||||
|
||||
{{subject_name, provider.provides(config.resource)}, config}
|
||||
end)
|
||||
end)
|
||||
|> Map.new()
|
||||
|> Macro.escape()
|
||||
|
||||
quote do
|
||||
require Ash.Api.Info
|
||||
use Plug.Router
|
||||
plug(:match)
|
||||
plug(:dispatch)
|
||||
|
||||
match("/:subject_name/:provider",
|
||||
to: AshAuthentication.Plug.Dispatcher,
|
||||
init_opts: [{:request, unquote(routes), unquote(return_to)}]
|
||||
)
|
||||
routes =
|
||||
unquote(otp_app)
|
||||
|> Application.compile_env(:ash_apis, [])
|
||||
|> Stream.flat_map(&Ash.Api.Info.depend_on_resources(&1))
|
||||
|> Stream.filter(&(AshAuthentication in Spark.extensions(&1)))
|
||||
|> Stream.flat_map(&Info.authentication_strategies/1)
|
||||
|> Stream.flat_map(fn strategy ->
|
||||
strategy
|
||||
|> Strategy.routes()
|
||||
|> Stream.map(fn {path, phase} -> {path, {phase, strategy, unquote(return_to)}} end)
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
match("/:subject_name/:provider/callback",
|
||||
to: AshAuthentication.Plug.Dispatcher,
|
||||
init_opts: [{:callback, unquote(routes), unquote(return_to)}]
|
||||
)
|
||||
for {path, config} <- routes do
|
||||
match(path, to: AshAuthentication.Plug.Dispatcher, init_opts: [config])
|
||||
end
|
||||
|
||||
match(_,
|
||||
to: AshAuthentication.Plug.Dispatcher,
|
||||
init_opts: [{:noop, [], unquote(return_to)}]
|
||||
)
|
||||
match(_, to: AshAuthentication.Plug.Dispatcher, init_opts: [unquote(return_to)])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,128 +0,0 @@
|
|||
defmodule AshAuthentication.Provider do
|
||||
@moduledoc false
|
||||
alias Ash.Resource
|
||||
alias Plug.Conn
|
||||
|
||||
@doc """
|
||||
The name of the provider for routing purposes, eg: "github".
|
||||
"""
|
||||
@callback provides(Resource.t()) :: String.t()
|
||||
|
||||
@doc """
|
||||
Given some credentials for a potentially existing user, verify the credentials
|
||||
and generate a token.
|
||||
|
||||
In the case of OAuth style providers, this is the only action that is likely to be called.
|
||||
"""
|
||||
@callback sign_in_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any}
|
||||
|
||||
@doc """
|
||||
Given some information about a potential user of the system attempt to create the record.
|
||||
|
||||
Only used by the "password authentication" provider at this time.
|
||||
"""
|
||||
@callback register_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any}
|
||||
|
||||
@doc """
|
||||
Whether the provider has a separate registration step.
|
||||
"""
|
||||
@callback has_register_step?(Resource.t()) :: boolean
|
||||
|
||||
@doc """
|
||||
A function plug which can handle the callback phase.
|
||||
"""
|
||||
@callback callback_plug(Conn.t(), any) :: Conn.t()
|
||||
|
||||
@doc """
|
||||
A function plug which can handle the request phase.
|
||||
"""
|
||||
@callback request_plug(Conn.t(), any) :: Conn.t()
|
||||
|
||||
@doc """
|
||||
Is this extension enabled for this resource?
|
||||
"""
|
||||
@callback enabled?(Resource.t()) :: boolean
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
@behaviour AshAuthentication.Provider
|
||||
|
||||
@doc """
|
||||
The name of the provider to be used in routes.
|
||||
|
||||
The default implementation derives it from the module name removing any
|
||||
"Authentication" suffix.
|
||||
|
||||
Overridable.
|
||||
"""
|
||||
@impl true
|
||||
@spec provides(Resource.t()) :: String.t()
|
||||
def provides(_resource) do
|
||||
__MODULE__
|
||||
|> Module.split()
|
||||
|> List.last()
|
||||
|> String.trim_trailing("Authentication")
|
||||
|> Macro.underscore()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handle a request for this extension to sign in a user.
|
||||
|
||||
Defaults to returning an error. Overridable.
|
||||
"""
|
||||
@impl true
|
||||
def sign_in_action(_resource, _attributes),
|
||||
do: {:error, "Sign in not supported by `#{inspect(__MODULE__)}`"}
|
||||
|
||||
@doc """
|
||||
Handle a request for this extension to register a user.
|
||||
|
||||
Defaults to returning an error. Overridable.
|
||||
"""
|
||||
@impl true
|
||||
def register_action(_resource, _attributes),
|
||||
do: {:error, "Registration not supported by `#{inspect(__MODULE__)}`"}
|
||||
|
||||
@doc """
|
||||
Handle an inbound request to the `request` path.
|
||||
|
||||
Defaults to returning the `conn` unchanged. Overridable.
|
||||
"""
|
||||
@impl true
|
||||
def request_plug(conn, _config), do: conn
|
||||
|
||||
@doc """
|
||||
Handle an inbound request to the `callback` path.
|
||||
|
||||
Defaults to returning the `conn` unchanged. Overridable.
|
||||
"""
|
||||
@impl true
|
||||
def callback_plug(conn, _config), do: conn
|
||||
|
||||
@doc """
|
||||
Does this extension require a separate register step?
|
||||
|
||||
Defaults to `false`. Overridable.
|
||||
"""
|
||||
@impl true
|
||||
def has_register_step?(_resource), do: false
|
||||
|
||||
@doc """
|
||||
Is `resource` supported by this provider?
|
||||
|
||||
Defaults to `false`. Overridable.
|
||||
"""
|
||||
@impl true
|
||||
@spec enabled?(Resource.t()) :: boolean
|
||||
def enabled?(resource), do: __MODULE__ in Spark.extensions(resource)
|
||||
|
||||
defoverridable provides: 1,
|
||||
sign_in_action: 2,
|
||||
register_action: 2,
|
||||
request_plug: 2,
|
||||
callback_plug: 2,
|
||||
has_register_step?: 1,
|
||||
enabled?: 1
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,10 +0,0 @@
|
|||
defmodule AshAuthentication.ProviderIdentity.Info do
|
||||
@moduledoc """
|
||||
Generated configuration functions based on a resource's token DSL
|
||||
configuration.
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication.ProviderIdentity,
|
||||
sections: [:provider_identity]
|
||||
end
|
|
@ -10,16 +10,22 @@ defmodule AshAuthentication.Secret do
|
|||
defmodule MyApp.GetSecret do
|
||||
use AshAuthentication.Secret
|
||||
|
||||
def secret_for([:oauth2_authentication, :client_id], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_id)
|
||||
def secret_for([:oauth2_authentication, :client_secret], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_secret)
|
||||
def secret_for([:authentication, :strategies, :oauth2, :client_id], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_id)
|
||||
def secret_for([:authentication, :strategies, :oauth2, :client_secret], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_secret)
|
||||
end
|
||||
|
||||
defmodule MyApp.User do
|
||||
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.OAuth2Authentication]
|
||||
defmodule MyApp.Accounts.User do
|
||||
use Ash.Resource, extensions: [AshAuthentication]
|
||||
|
||||
oauth2_authentication do
|
||||
client_id MyApp.GetSecret
|
||||
client_secret MyApp.GetSecret
|
||||
authentication do
|
||||
api MyApp.Accounts
|
||||
|
||||
strategies do
|
||||
oauth2 do
|
||||
client_id MyApp.GetSecret
|
||||
client_secret MyApp.GetSecret
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
@ -28,11 +34,17 @@ defmodule AshAuthentication.Secret do
|
|||
|
||||
```elixir
|
||||
defmodule MyApp.User do
|
||||
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.OAuth2Authentication]
|
||||
use Ash.Resource, extensions: [AshAuthentication]
|
||||
|
||||
oauth2_authentication do
|
||||
client_id fn _secret, _resource, _opts ->
|
||||
Application.fetch_env(:my_app, :oauth_client_id)
|
||||
authentication do
|
||||
api MyApp.Accounts
|
||||
|
||||
strategies do
|
||||
oauth2 do
|
||||
client_id fn _secret, _resource, _opts ->
|
||||
Application.fetch_env(:my_app, :oauth_client_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -43,8 +55,7 @@ defmodule AshAuthentication.Secret do
|
|||
Because you may wish to reuse this module for a number of different providers
|
||||
and resources, the first argument passed to the callback is the "secret name",
|
||||
it contains the "path" to the option being set. The path is made up of a list
|
||||
containing the DSL section name (`oauth2_authentication` etc) as an atom and
|
||||
the property name as an atom.
|
||||
containing the DSL path to the secret.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
|
|
|
@ -19,7 +19,7 @@ defmodule AshAuthentication.SecretFunction do
|
|||
fun.(secret_name, resource, opts)
|
||||
|
||||
{{m, f, a}, _opts} when is_atom(m) and is_atom(f) and is_list(a) ->
|
||||
apply(m, f, [secret_name, resource, a])
|
||||
apply(m, f, [secret_name, resource | a])
|
||||
|
||||
{nil, opts} ->
|
||||
raise "Invalid options given to `secret_for/3` callback: `#{inspect(opts)}`."
|
||||
|
|
|
@ -18,7 +18,6 @@ defmodule AshAuthentication.Sender do
|
|||
defmodule MyApp.PasswordResetSender do
|
||||
use AshAuthentication.PasswordReset.Sender
|
||||
import Swoosh.Email
|
||||
alias MyAppWeb.Router.Helpers, as: Routes
|
||||
|
||||
def send(user, reset_token, _opts) do
|
||||
new()
|
||||
|
@ -33,7 +32,7 @@ defmodule AshAuthentication.Sender do
|
|||
Someone (maybe you) has requested a password reset for your account.
|
||||
If you did not initiate this request then please ignore this email.
|
||||
</p>
|
||||
<a href="#{Routes.auth_url(MyAppWeb.Endpoint, :reset_password, token: reset_token)}">
|
||||
<a href="#{"https://example.com/user/password/reset?#{URI.encode_query(reset_token: reset_token)}}">
|
||||
Click here to reset
|
||||
</a>
|
||||
")
|
||||
|
@ -42,24 +41,39 @@ defmodule AshAuthentication.Sender do
|
|||
end
|
||||
|
||||
defmodule MyApp.Accounts.User do
|
||||
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication, AshAuthentication.PasswordRest]
|
||||
use Ash.Resource, extensions: [AshAuthentication]
|
||||
|
||||
password_reset do
|
||||
sender MyApp.PasswordResetSender
|
||||
authentication do
|
||||
api MyApp.Accounts
|
||||
|
||||
strategies do
|
||||
password :password do
|
||||
resettable do
|
||||
sender MyApp.PasswordResetSender
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
You can also implment it directly as a function:
|
||||
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.User do
|
||||
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication, AshAuthentication.PasswordRest]
|
||||
use Ash.Resource, extensions: [AshAuthentication]
|
||||
|
||||
password_reset do
|
||||
sender fn user, token, _opt ->
|
||||
MyApp.Mailer.send_password_reset_email(user, token)
|
||||
authentication do
|
||||
api MyApp.Accounts
|
||||
|
||||
strategies do
|
||||
password :password do
|
||||
resettable do
|
||||
sender fn user, token ->
|
||||
MyApp.Mailer.send_password_reset_email(user, token)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
172
lib/ash_authentication/strategies/confirmation.ex
Normal file
172
lib/ash_authentication/strategies/confirmation.ex
Normal file
|
@ -0,0 +1,172 @@
|
|||
defmodule AshAuthentication.Strategy.Confirmation do
|
||||
@default_lifetime_days 3
|
||||
|
||||
@moduledoc """
|
||||
Strategy for authenticating sensitive changes.
|
||||
|
||||
Sometimes when creating a new user, or changing a sensitive attribute (such as
|
||||
their email address) you may want to for the user to confirm by way of sending
|
||||
them a confirmation token to prove that it was really them that took the
|
||||
action.
|
||||
|
||||
See the DSL documentation for `AshAuthentication` for information on how to
|
||||
configure it.
|
||||
"""
|
||||
|
||||
defstruct token_lifetime: nil,
|
||||
monitor_fields: [],
|
||||
confirmed_at_field: :confirmed_at,
|
||||
confirm_on_create?: true,
|
||||
confirm_on_update?: true,
|
||||
inhibit_updates?: false,
|
||||
sender: nil,
|
||||
confirm_action_name: :confirm,
|
||||
resource: nil,
|
||||
provider: :confirmation,
|
||||
name: :confirm
|
||||
|
||||
alias Ash.Changeset
|
||||
alias AshAuthentication.{Jwt, Strategy.Confirmation}
|
||||
|
||||
@type t :: %Confirmation{
|
||||
token_lifetime: hours :: pos_integer,
|
||||
monitor_fields: [atom],
|
||||
confirmed_at_field: atom,
|
||||
confirm_on_create?: boolean,
|
||||
confirm_on_update?: boolean,
|
||||
inhibit_updates?: boolean,
|
||||
sender: nil | {module, keyword},
|
||||
confirm_action_name: atom,
|
||||
resource: module,
|
||||
provider: :confirmation,
|
||||
name: :confirm
|
||||
}
|
||||
|
||||
@doc """
|
||||
Generate a confirmation token for a changeset.
|
||||
|
||||
This will generate a token with the `"act"` claim set to the confirmation
|
||||
action for the strategy, and the `"chg"` claim will contain any changes.
|
||||
|
||||
FIXME: The "chg" claim should encrypt the contents of the changes so as to not
|
||||
leak users' private details.
|
||||
"""
|
||||
@spec confirmation_token(Confirmation.t(), Changeset.t()) :: {:ok, String.t()} | :error
|
||||
def confirmation_token(strategy, changeset) do
|
||||
changes =
|
||||
strategy.monitor_fields
|
||||
|> Stream.filter(&Changeset.changing_attribute?(changeset, &1))
|
||||
|> Stream.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))})
|
||||
|> Map.new()
|
||||
|
||||
claims = %{"act" => strategy.confirm_action_name, "chg" => changes}
|
||||
token_lifetime = strategy.token_lifetime * 3600
|
||||
|
||||
case Jwt.token_for_user(changeset.data, claims, token_lifetime: token_lifetime) do
|
||||
{:ok, token, _claims} -> {:ok, token}
|
||||
:error -> :error
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec schema :: keyword
|
||||
def schema do
|
||||
[
|
||||
token_lifetime: [
|
||||
type: :pos_integer,
|
||||
doc: """
|
||||
How long should the confirmation token be valid, in hours.
|
||||
|
||||
Defaults to #{@default_lifetime_days} days.
|
||||
""",
|
||||
default: @default_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 confirmation token 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: false
|
||||
],
|
||||
sender: [
|
||||
type:
|
||||
{:spark_function_behaviour, AshAuthentication.Sender,
|
||||
{AshAuthentication.SenderFunction, 2}},
|
||||
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.
|
||||
|
||||
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
|
30
lib/ash_authentication/strategies/confirmation/actions.ex
Normal file
30
lib/ash_authentication/strategies/confirmation/actions.ex
Normal file
|
@ -0,0 +1,30 @@
|
|||
defmodule AshAuthentication.Strategy.Confirmation.Actions do
|
||||
@moduledoc """
|
||||
Actions for the confirmation strategy.
|
||||
|
||||
Provides the code interface for working with resources via confirmation.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Resource}
|
||||
alias AshAuthentication.{Errors.InvalidToken, Info, Jwt, Strategy.Confirmation}
|
||||
|
||||
@doc """
|
||||
Attempt to confirm a user.
|
||||
"""
|
||||
@spec confirm(Confirmation.t(), map) :: {:ok, Resource.record()} | {:error, any}
|
||||
def confirm(strategy, params) do
|
||||
with {:ok, api} <- Info.authentication_api(strategy.resource),
|
||||
{:ok, token} <- Map.fetch(params, "confirm"),
|
||||
{:ok, %{"sub" => subject}, _} <- Jwt.verify(token, strategy.resource),
|
||||
{:ok, user} <- AshAuthentication.subject_to_user(subject, strategy.resource) do
|
||||
user
|
||||
|> Changeset.new()
|
||||
|> Changeset.set_context(%{strategy: strategy})
|
||||
|> Changeset.for_update(strategy.confirm_action_name, params)
|
||||
|> api.update()
|
||||
else
|
||||
:error -> {:error, InvalidToken.exception(type: :confirmation)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,50 @@
|
|||
defmodule AshAuthentication.Strategy.Confirmation.ConfirmChange do
|
||||
@moduledoc """
|
||||
Performs a change based on the contents of a confirmation token.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias AshAuthentication.Jwt
|
||||
|
||||
alias Ash.{
|
||||
Changeset,
|
||||
Error.Changes.InvalidArgument,
|
||||
Error.Framework.AssumptionFailed,
|
||||
Resource.Change
|
||||
}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
case Map.fetch(changeset.context, :strategy) do
|
||||
{:ok, strategy} ->
|
||||
do_change(changeset, strategy)
|
||||
|
||||
:error ->
|
||||
raise AssumptionFailed, message: "Strategy is missing from the changeset context."
|
||||
end
|
||||
end
|
||||
|
||||
defp do_change(changeset, strategy) do
|
||||
changeset
|
||||
|> Changeset.before_action(fn changeset ->
|
||||
with token when is_binary(token) <- Changeset.get_argument(changeset, :confirm),
|
||||
{:ok, %{"act" => action, "chg" => changes}, _} <-
|
||||
Jwt.verify(token, changeset.resource),
|
||||
true <- to_string(strategy.confirm_action_name) == action do
|
||||
allowed_changes =
|
||||
if strategy.inhibit_updates?,
|
||||
do: Map.take(changes, Enum.map(strategy.monitor_fields, &to_string/1)),
|
||||
else: %{}
|
||||
|
||||
changeset
|
||||
|> Changeset.change_attributes(allowed_changes)
|
||||
|> Changeset.change_attribute(strategy.confirmed_at_field, DateTime.utc_now())
|
||||
else
|
||||
_ ->
|
||||
raise InvalidArgument, field: :confirm, message: "is not valid"
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,97 @@
|
|||
defmodule AshAuthentication.Strategy.Confirmation.ConfirmationHookChange do
|
||||
@moduledoc """
|
||||
Triggers a confirmation flow when one of the monitored fields is changed.
|
||||
|
||||
Optionally inhibits changes to monitored fields on update.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias Ash.{Changeset, Resource.Change}
|
||||
alias AshAuthentication.{Info, Strategy.Confirmation}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
case Info.strategy(changeset.resource, :confirm) do
|
||||
{:ok, strategy} ->
|
||||
do_change(changeset, strategy)
|
||||
|
||||
:error ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp do_change(changeset, strategy) do
|
||||
changeset
|
||||
|> Changeset.before_action(fn changeset ->
|
||||
changeset
|
||||
|> not_confirm_action(strategy)
|
||||
|> should_confirm_action_type(strategy)
|
||||
|> monitored_field_changing(strategy)
|
||||
|> changes_would_be_valid()
|
||||
|> maybe_inhibit_updates(strategy)
|
||||
|> maybe_perform_confirmation(strategy, changeset)
|
||||
end)
|
||||
end
|
||||
|
||||
defp not_confirm_action(%Changeset{} = changeset, strategy)
|
||||
when changeset.action != strategy.confirm_action_name,
|
||||
do: changeset
|
||||
|
||||
defp not_confirm_action(_changeset, _strategy), do: nil
|
||||
|
||||
defp should_confirm_action_type(%Changeset{} = changeset, strategy)
|
||||
when changeset.action_type == :create and strategy.confirm_on_create?,
|
||||
do: changeset
|
||||
|
||||
defp should_confirm_action_type(%Changeset{} = changeset, strategy)
|
||||
when changeset.action_type == :update and strategy.confirm_on_update?,
|
||||
do: changeset
|
||||
|
||||
defp should_confirm_action_type(_changeset, _strategy), do: nil
|
||||
|
||||
defp monitored_field_changing(%Changeset{} = changeset, strategy) do
|
||||
if Enum.any?(strategy.monitor_fields, &Changeset.changing_attribute?(changeset, &1)),
|
||||
do: changeset,
|
||||
else: nil
|
||||
end
|
||||
|
||||
defp monitored_field_changing(_changeset, _strategy), do: nil
|
||||
|
||||
defp changes_would_be_valid(%Changeset{} = changeset) when changeset.valid?, do: changeset
|
||||
defp changes_would_be_valid(_), do: nil
|
||||
|
||||
defp maybe_inhibit_updates(%Changeset{} = changeset, strategy)
|
||||
when changeset.action_type == :update and strategy.inhibit_updates? do
|
||||
strategy.monitor_fields
|
||||
|> Enum.reduce(changeset, &Changeset.clear_change(&2, &1))
|
||||
end
|
||||
|
||||
defp maybe_inhibit_updates(changeset, _strategy), do: changeset
|
||||
|
||||
defp maybe_perform_confirmation(%Changeset{} = changeset, strategy, original_changeset) do
|
||||
changeset
|
||||
|> Changeset.after_action(fn _changeset, user ->
|
||||
strategy
|
||||
|> Confirmation.confirmation_token(original_changeset)
|
||||
|> case do
|
||||
{:ok, token} ->
|
||||
{sender, send_opts} = strategy.sender
|
||||
sender.send(user, token, send_opts)
|
||||
|
||||
metadata =
|
||||
user.__metadata__
|
||||
|> Map.put(:confirmation_token, token)
|
||||
|
||||
{:ok, %{user | __metadata__: metadata}}
|
||||
|
||||
_ ->
|
||||
{:ok, user}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp maybe_perform_confirmation(_changeset, _strategy, original_changeset),
|
||||
do: original_changeset
|
||||
end
|
22
lib/ash_authentication/strategies/confirmation/plug.ex
Normal file
22
lib/ash_authentication/strategies/confirmation/plug.ex
Normal file
|
@ -0,0 +1,22 @@
|
|||
defmodule AshAuthentication.Strategy.Confirmation.Plug do
|
||||
@moduledoc """
|
||||
Handlers for incoming OAuth2 HTTP requests.
|
||||
"""
|
||||
|
||||
alias AshAuthentication.{Strategy, Strategy.Confirmation}
|
||||
alias Plug.Conn
|
||||
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
|
||||
|
||||
@doc """
|
||||
Attempt to perform a confirmation.
|
||||
"""
|
||||
@spec confirm(Conn.t(), Confirmation.t()) :: Conn.t()
|
||||
def confirm(conn, strategy) do
|
||||
result =
|
||||
strategy
|
||||
|> Strategy.action(:confirm, conn.params)
|
||||
|
||||
conn
|
||||
|> store_authentication_result(result)
|
||||
end
|
||||
end
|
49
lib/ash_authentication/strategies/confirmation/strategy.ex
Normal file
49
lib/ash_authentication/strategies/confirmation/strategy.ex
Normal file
|
@ -0,0 +1,49 @@
|
|||
defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Confirmation do
|
||||
@moduledoc """
|
||||
Implementation of `AshAuthentication.Strategy` for
|
||||
`AshAuthentication.Strategy.Confirmation`.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.{Info, Strategy, Strategy.Confirmation}
|
||||
alias Plug.Conn
|
||||
|
||||
@typedoc "The request phases supposed by this strategy"
|
||||
@type phase :: :confirm
|
||||
|
||||
@typedoc "The actions supported by this strategy"
|
||||
@type action :: :confirm
|
||||
|
||||
@doc false
|
||||
@spec phases(Confirmation.t()) :: [phase]
|
||||
def phases(_), do: [:confirm]
|
||||
|
||||
@doc false
|
||||
@spec actions(Confirmation.t()) :: [action]
|
||||
def actions(_), do: [:confirm]
|
||||
|
||||
@doc false
|
||||
@spec method_for_phase(Confirmation.t(), phase) :: Strategy.http_method()
|
||||
def method_for_phase(_, _), do: :get
|
||||
|
||||
@doc false
|
||||
@spec routes(Confirmation.t()) :: [Strategy.route()]
|
||||
def routes(strategy) do
|
||||
subject_name = Info.authentication_subject_name!(strategy.resource)
|
||||
|
||||
path =
|
||||
[subject_name, strategy.name]
|
||||
|> Enum.map(&to_string/1)
|
||||
|> Path.join()
|
||||
|
||||
[{"/#{path}", :confirm}]
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec plug(Confirmation.t(), phase, Conn.t()) :: Conn.t()
|
||||
def plug(strategy, :confirm, conn), do: Confirmation.Plug.confirm(conn, strategy)
|
||||
|
||||
@doc false
|
||||
@spec action(Confirmation.t(), action, map) :: {:ok, Resource.record()} | {:error, any}
|
||||
def action(strategy, :confirm, params), do: Confirmation.Actions.confirm(strategy, params)
|
||||
end
|
222
lib/ash_authentication/strategies/confirmation/transformer.ex
Normal file
222
lib/ash_authentication/strategies/confirmation/transformer.ex
Normal file
|
@ -0,0 +1,222 @@
|
|||
defmodule AshAuthentication.Strategy.Confirmation.Transformer do
|
||||
@moduledoc """
|
||||
DSL transformer for confirmation strategy.
|
||||
|
||||
Ensures that there is only ever one present and that it is correctly
|
||||
configured.
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
alias Ash.{Resource, Type}
|
||||
alias AshAuthentication.{GenerateTokenChange, Info, Sender, Strategy.Confirmation}
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
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()
|
||||
|> Enum.filter(&is_struct(&1, Confirmation))
|
||||
|> case do
|
||||
[] ->
|
||||
{:ok, dsl_state}
|
||||
|
||||
[strategy] ->
|
||||
transform_strategy(strategy, dsl_state)
|
||||
|
||||
[_ | _] ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:authentication, :strategies, :confirmation],
|
||||
message: "Multiple confirmation strategies are not supported"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp transform_strategy(strategy, dsl_state) do
|
||||
with :ok <- validate_token_generation_enabled(dsl_state),
|
||||
{:ok, {sender, _opts}} <- Map.fetch(strategy, :sender),
|
||||
:ok <- validate_behaviour(sender, Sender),
|
||||
:ok <- validate_monitor_fields(dsl_state, strategy),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
strategy.confirm_action_name,
|
||||
&build_confirm_action(&1, strategy)
|
||||
),
|
||||
:ok <- validate_confirm_action(dsl_state, strategy),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(
|
||||
dsl_state,
|
||||
strategy.confirmed_at_field,
|
||||
&build_confirmed_at_attribute(&1, strategy)
|
||||
),
|
||||
:ok <- validate_confirmed_at_attribute(dsl_state, strategy),
|
||||
{:ok, dsl_state} <- maybe_build_change(dsl_state, Confirmation.ConfirmationHookChange),
|
||||
{:ok, resource} <- persisted_option(dsl_state, :module) do
|
||||
dsl_state =
|
||||
dsl_state
|
||||
|> Transformer.replace_entity(
|
||||
[:authentication, :strategies],
|
||||
%{strategy | resource: resource},
|
||||
&(&1.name == strategy.name)
|
||||
)
|
||||
|
||||
{:ok, dsl_state}
|
||||
else
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:error,
|
||||
DslError.exception(path: [:authentication, :strategies, :confirmation], message: reason)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
||||
:error ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:authentication, :strategies, :confirmation],
|
||||
message: "Configuration error"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_monitor_fields(_dsl_state, %{monitor_fields: []}),
|
||||
do:
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:authentication, :strategies, :confirmation],
|
||||
message: "You should be monitoring at least one field"
|
||||
)}
|
||||
|
||||
defp validate_monitor_fields(dsl_state, strategy) do
|
||||
Enum.reduce_while(strategy.monitor_fields, :ok, fn field, :ok ->
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, field),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
|
||||
:ok <- maybe_validate_eager_checking(dsl_state, strategy, field, resource) do
|
||||
{:cont, :ok}
|
||||
else
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp maybe_validate_eager_checking(_dsl_state, %{inhibit_updates?: false}, _, _), do: :ok
|
||||
|
||||
defp maybe_validate_eager_checking(dsl_state, _strategy, field, resource) do
|
||||
dsl_state
|
||||
|> Resource.Info.identities()
|
||||
|> Enum.find(&(&1.keys == [field]))
|
||||
|> case do
|
||||
%{eager_check_with: nil} ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:identities, :identity],
|
||||
message:
|
||||
"The attribute `#{inspect(field)}` on the resource `#{inspect(resource)}` needs the `eager_check_with` property set so that inhibited changes are still validated."
|
||||
)}
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp build_confirm_action(_dsl_state, strategy) do
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :argument,
|
||||
name: :confirm,
|
||||
type: Type.String,
|
||||
allow_nil?: false
|
||||
)
|
||||
]
|
||||
|
||||
changes = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
|
||||
change: Confirmation.ConfirmChange
|
||||
),
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
|
||||
change: GenerateTokenChange
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :update,
|
||||
name: strategy.confirm_action_name,
|
||||
accept: strategy.monitor_fields,
|
||||
arguments: arguments,
|
||||
changes: changes
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_confirm_action(dsl_state, strategy) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, strategy.confirm_action_name),
|
||||
:ok <- validate_action_has_change(action, Confirmation.ConfirmChange),
|
||||
:ok <- validate_action_argument_option(action, :confirm, :allow_nil?, [false]),
|
||||
:ok <- validate_action_has_change(action, GenerateTokenChange) do
|
||||
validate_action_argument_option(action, :confirm, :type, [Type.String])
|
||||
end
|
||||
end
|
||||
|
||||
defp build_confirmed_at_attribute(_dsl_state, strategy) do
|
||||
Transformer.build_entity(Resource.Dsl, [:attributes], :attribute,
|
||||
name: strategy.confirmed_at_field,
|
||||
type: Type.UtcDatetimeUsec,
|
||||
allow_nil?: true,
|
||||
writable?: true
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_confirmed_at_attribute(dsl_state, strategy) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, strategy.confirmed_at_field),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :type, [Type.UtcDatetimeUsec]) do
|
||||
:ok
|
||||
else
|
||||
:error ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:confirmation],
|
||||
message: "The `confirmed_at_field` option must be set."
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_build_change(dsl_state, change_module) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
changes <- Resource.Info.changes(resource),
|
||||
false <- change_module in changes,
|
||||
{:ok, change} <-
|
||||
Transformer.build_entity(Resource.Dsl, [:changes], :change, change: change_module) do
|
||||
{:ok, Transformer.add_entity(dsl_state, [:changes], change)}
|
||||
else
|
||||
true -> {:ok, dsl_state}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
272
lib/ash_authentication/strategies/oauth2.ex
Normal file
272
lib/ash_authentication/strategies/oauth2.ex
Normal file
|
@ -0,0 +1,272 @@
|
|||
defmodule AshAuthentication.Strategy.OAuth2 do
|
||||
import AshAuthentication.Dsl
|
||||
|
||||
@moduledoc """
|
||||
Strategy for authenticating using an OAuth 2.0 server as the source of truth.
|
||||
|
||||
This strategy wraps the excellent [`assent`](https://hex.pm/packages/assent)
|
||||
package, which provides OAuth 2.0 capabilities.
|
||||
|
||||
In order to use OAuth 2.0 authentication on your resource, it needs to meet
|
||||
the following minimum criteria:
|
||||
|
||||
1. Have a primary key.
|
||||
2. Provide a strategy-specific action, either register or sign-in.
|
||||
3. Provide configuration for OAuth2 destinations, secrets, etc.
|
||||
|
||||
### Example:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.User do
|
||||
use Ash.Resource,
|
||||
extensions: [AshAuthentication]
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :email, :ci_string, allow_nil?: false
|
||||
end
|
||||
|
||||
authentication do
|
||||
api MyApp.Accounts
|
||||
|
||||
strategies do
|
||||
oauth2 :example do
|
||||
client_id "OAuth Client ID"
|
||||
redirect_uri "https://my.app/"
|
||||
client_secret "My Super Secret Secret"
|
||||
site "https://auth.example.com/"
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Secrets and runtime configuration
|
||||
|
||||
In order to use OAuth 2.0 you need to provide a varying number of secrets and
|
||||
other configuration which may change based on runtime environment. The
|
||||
`AshAuthentication.Secret` behaviour is provided to accomodate this. This
|
||||
allows you to provide configuration either directly on the resource (ie as a
|
||||
string), as an anonymous function, or as a module.
|
||||
|
||||
> ### Warning {: .warning}
|
||||
>
|
||||
> We **strongly** urge you not to sure actual secrets in your code or
|
||||
> repository.
|
||||
|
||||
### Examples:
|
||||
|
||||
Providing configuration as an anonymous function:
|
||||
|
||||
```elixir
|
||||
oauth2 do
|
||||
client_secret fn _path, resource ->
|
||||
Application.fetch_env(:my_app, resource, :oauth2_client_secret)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Providing configuration as a module:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Secrets do
|
||||
use AshAuthentication.Secret
|
||||
|
||||
def secret_for([:authentication, :strategies, :example, :client_secret], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth2_client_secret)
|
||||
end
|
||||
|
||||
# and in your stragegies:
|
||||
|
||||
oauth2 :example do
|
||||
client_secret MyApp.Secrets
|
||||
end
|
||||
```
|
||||
|
||||
## User identities
|
||||
|
||||
Because your users can be signed in via multiple providers at once, you can
|
||||
specify an `identity_resource` in the DSL configuration which points to a
|
||||
seperate Ash resource which has the `AshAuthentication.UserIdentity` extension
|
||||
present. This resource will be used to store details of the providers in use
|
||||
by each user and a relationship will be added to the user resource.
|
||||
|
||||
Setting the `identity_resource` will cause extra validations to be applied to
|
||||
your resource so that changes are tracked correctly on sign-in or
|
||||
registration.
|
||||
|
||||
## Actions
|
||||
|
||||
When using an OAuth 2.0 provider you need to declare either a "register" or
|
||||
"sign-in" action. The reason for this is that it's not possible for us to
|
||||
know ahead of time how you want to manage the link between your user resources
|
||||
and the "user info" provided by the OAuth server.
|
||||
|
||||
Both actions receive the following two arguments:
|
||||
|
||||
1. `user_info` - a map with string keys containing the [OpenID Successful
|
||||
UserInfo
|
||||
response](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse).
|
||||
Usually this will be used to populate your email, nickname or other
|
||||
identifying field.
|
||||
2. `oauth_tokens` a map with string keys containing the [OpenID Successful
|
||||
Token
|
||||
response](https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse)
|
||||
(or similar).
|
||||
|
||||
The actions themselves can be interacted with directly via the
|
||||
`AshAuthentication.Strategy` protocol, but you are more likely to interact
|
||||
with them via the web/plugs.
|
||||
|
||||
### Sign-in
|
||||
|
||||
The sign-in action is called when a successful OAuth2 callback is received.
|
||||
You should use it to constrain the query to the correct user based on the
|
||||
arguments provided.
|
||||
|
||||
This action is only needed when the `registration_enabled?` DSL settings is
|
||||
set to `false`.
|
||||
|
||||
### Registration
|
||||
|
||||
The register action is a little more complicated than the sign-in action,
|
||||
because we cannot tell the difference between a new user and a returning user
|
||||
(they all use the same OAuth flow). In order to handle this your register
|
||||
action must be defined as an upset with a configured `upsert_identity` (see
|
||||
example below).
|
||||
|
||||
### Examples:
|
||||
|
||||
Providing sign-in to users who already exist in the database (and by extension
|
||||
rejecting new users):
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.User do
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :email, :ci_string, allow_nil?: false
|
||||
end
|
||||
|
||||
actions do
|
||||
read :sign_in_with_example do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
||||
|
||||
filter expr(email == get_path(^arg(:user_info), [:email]))
|
||||
end
|
||||
end
|
||||
|
||||
authentication do
|
||||
api MyApp.Accounts
|
||||
|
||||
strategies do
|
||||
oauth2 :example do
|
||||
registration_enabled? false
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Providing registration or sign-in to all comers:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.User do
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :email, :ci_string, allow_nil?: false
|
||||
end
|
||||
|
||||
actions do
|
||||
create :register_with_oauth2 do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
upsert? true
|
||||
upsert_identity :email
|
||||
|
||||
change AshAuthentication.GenerateTokenChange
|
||||
change fn changeset, _ctx ->
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
||||
changeset
|
||||
|> Changeset.change_attribute(:email, user_info["email"])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
authentication do
|
||||
api MyApp.Accounts
|
||||
|
||||
strategies do
|
||||
oauth2 :example do
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Plugs
|
||||
|
||||
OAuth 2.0 is (usually) a browser-based flow. This means that you're most
|
||||
likely to interact with this strategy via it's plugs. There are two phases to
|
||||
authentication with OAuth 2.0:
|
||||
|
||||
1. The request phase, where the user's browser is redirected to the remote
|
||||
authentication provider for authentication.
|
||||
2. The callback phase, where the provider redirects the user back to your app
|
||||
to create a local database record, session, etc.
|
||||
|
||||
|
||||
## DSL Documentation
|
||||
|
||||
#{Spark.Dsl.Extension.doc_entity(strategy(:oauth2))}
|
||||
"""
|
||||
|
||||
defstruct client_id: nil,
|
||||
site: nil,
|
||||
auth_method: :client_secret_post,
|
||||
client_secret: nil,
|
||||
authorize_path: nil,
|
||||
token_path: nil,
|
||||
user_path: nil,
|
||||
private_key: nil,
|
||||
redirect_uri: nil,
|
||||
authorization_params: [],
|
||||
registration_enabled?: true,
|
||||
register_action_name: nil,
|
||||
sign_in_action_name: nil,
|
||||
identity_resource: false,
|
||||
identity_relationship_name: :identities,
|
||||
identity_relationship_user_id_attribute: :user_id,
|
||||
provider: :oauth2,
|
||||
name: nil,
|
||||
resource: nil
|
||||
|
||||
alias AshAuthentication.Strategy.OAuth2
|
||||
|
||||
@type secret :: nil | String.t() | {module, keyword}
|
||||
|
||||
@type t :: %OAuth2{
|
||||
client_id: secret,
|
||||
site: secret,
|
||||
auth_method:
|
||||
nil
|
||||
| :client_secret_basic
|
||||
| :client_secret_post
|
||||
| :client_secret_jwt
|
||||
| :private_key_jwt,
|
||||
client_secret: secret,
|
||||
authorize_path: secret,
|
||||
token_path: secret,
|
||||
user_path: secret,
|
||||
private_key: secret,
|
||||
redirect_uri: secret,
|
||||
authorization_params: keyword,
|
||||
registration_enabled?: boolean,
|
||||
register_action_name: atom,
|
||||
sign_in_action_name: atom,
|
||||
identity_resource: module | false,
|
||||
identity_relationship_name: atom,
|
||||
identity_relationship_user_id_attribute: atom,
|
||||
provider: atom,
|
||||
name: atom,
|
||||
resource: module
|
||||
}
|
||||
end
|
64
lib/ash_authentication/strategies/oauth2/actions.ex
Normal file
64
lib/ash_authentication/strategies/oauth2/actions.ex
Normal file
|
@ -0,0 +1,64 @@
|
|||
defmodule AshAuthentication.Strategy.OAuth2.Actions do
|
||||
@moduledoc """
|
||||
Actions for the oauth2 strategy.
|
||||
|
||||
Provides the code interface for working with resources via an OAuth2 strategy.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Error.Invalid.NoSuchAction, Query, Resource}
|
||||
alias AshAuthentication.{Errors, Info, Strategy.OAuth2}
|
||||
|
||||
@doc """
|
||||
Attempt to sign in a user.
|
||||
"""
|
||||
@spec sign_in(OAuth2.t(), map) :: {:ok, Resource.record()} | {:error, any}
|
||||
def sign_in(%OAuth2{} = strategy, _params) when strategy.registration_enabled?,
|
||||
do:
|
||||
{:error,
|
||||
NoSuchAction.exception(
|
||||
resource: strategy.resource,
|
||||
action: strategy.sign_in_action_name,
|
||||
type: :read
|
||||
)}
|
||||
|
||||
def sign_in(%OAuth2{} = strategy, params) do
|
||||
api = Info.authentication_api!(strategy.resource)
|
||||
|
||||
strategy.resource
|
||||
|> Query.new()
|
||||
|> Query.set_context(%{strategy: strategy})
|
||||
|> Query.for_read(strategy.sign_in_action_name, params)
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, [user]} -> {:ok, user}
|
||||
_ -> {:error, Errors.AuthenticationFailed.exception([])}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempt to register a new user.
|
||||
"""
|
||||
@spec register(OAuth2.t(), map) :: {:ok, Resource.record()} | {:error, any}
|
||||
def register(%OAuth2{} = strategy, params) when strategy.registration_enabled? do
|
||||
api = Info.authentication_api!(strategy.resource)
|
||||
action = Resource.Info.action(strategy.resource, strategy.register_action_name, :create)
|
||||
|
||||
strategy.resource
|
||||
|> Changeset.new()
|
||||
|> Changeset.set_context(%{strategy: strategy})
|
||||
|> Changeset.for_create(strategy.register_action_name, params,
|
||||
upsert?: true,
|
||||
upsert_identity: action.upsert_identity
|
||||
)
|
||||
|> api.create()
|
||||
end
|
||||
|
||||
def register(%OAuth2{} = strategy, _params),
|
||||
do:
|
||||
{:error,
|
||||
NoSuchAction.exception(
|
||||
resource: strategy.resource,
|
||||
action: strategy.register_action_name,
|
||||
type: :create
|
||||
)}
|
||||
end
|
18
lib/ash_authentication/strategies/oauth2/default.ex
Normal file
18
lib/ash_authentication/strategies/oauth2/default.ex
Normal file
|
@ -0,0 +1,18 @@
|
|||
defmodule AshAuthentication.Strategy.OAuth2.Default do
|
||||
@moduledoc """
|
||||
Sets default values for values which can be configured at runtime and are not set.
|
||||
"""
|
||||
|
||||
use AshAuthentication.Secret
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec secret_for([atom], Ash.Resource.t(), keyword) :: {:ok, String.t()} | :error
|
||||
def secret_for(path, _resource, _opts), do: path |> Enum.reverse() |> List.first() |> default()
|
||||
|
||||
@doc false
|
||||
@spec default(atom) :: {:ok, String.t()}
|
||||
def default(:authorize_path), do: {:ok, "/oauth/authorize"}
|
||||
def default(:token_path), do: {:ok, "/oauth/access_token"}
|
||||
def default(:user_path), do: {:ok, "/user"}
|
||||
end
|
47
lib/ash_authentication/strategies/oauth2/identity_change.ex
Normal file
47
lib/ash_authentication/strategies/oauth2/identity_change.ex
Normal file
|
@ -0,0 +1,47 @@
|
|||
defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
|
||||
@moduledoc """
|
||||
Updates the identity resource when a user is registered.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias AshAuthentication.UserIdentity
|
||||
alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
|
||||
import AshAuthentication.Utils, only: [is_falsy: 1]
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
case Map.fetch(changeset.context, :strategy) do
|
||||
{:ok, strategy} ->
|
||||
do_change(changeset, strategy)
|
||||
|
||||
:error ->
|
||||
{:error,
|
||||
AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_change(changeset, strategy) when is_falsy(strategy.identity_resource), do: changeset
|
||||
|
||||
defp do_change(changeset, strategy) do
|
||||
changeset
|
||||
|> Changeset.after_action(fn changeset, user ->
|
||||
strategy.identity_resource
|
||||
|> UserIdentity.Actions.upsert(%{
|
||||
user_info: Changeset.get_argument(changeset, :user_info),
|
||||
oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens),
|
||||
strategy: strategy.name,
|
||||
user_id: user.id
|
||||
})
|
||||
|> case do
|
||||
{:ok, _identity} ->
|
||||
user
|
||||
|> changeset.api.load(strategy.identity_relationship_name)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
155
lib/ash_authentication/strategies/oauth2/plug.ex
Normal file
155
lib/ash_authentication/strategies/oauth2/plug.ex
Normal file
|
@ -0,0 +1,155 @@
|
|||
defmodule AshAuthentication.Strategy.OAuth2.Plug do
|
||||
@moduledoc """
|
||||
Handlers for incoming OAuth2 HTTP requests.
|
||||
"""
|
||||
|
||||
alias Ash.Error.Framework.AssumptionFailed
|
||||
alias AshAuthentication.{Errors, Info, Strategy, Strategy.OAuth2}
|
||||
alias Assent.{Config, HTTPAdapter.Mint}
|
||||
alias Assent.Strategy.OAuth2, as: Assent
|
||||
alias Plug.Conn
|
||||
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
|
||||
import Plug.Conn
|
||||
|
||||
@doc """
|
||||
Perform the request phase of OAuth2.
|
||||
|
||||
Builds a redirection URL based on the provider configuration and redirects the
|
||||
user to that endpoint.
|
||||
"""
|
||||
@spec request(Conn.t(), OAuth2.t()) :: Conn.t()
|
||||
def request(conn, strategy) do
|
||||
with {:ok, config} <- config_for(strategy),
|
||||
{:ok, session_key} <- session_key(strategy),
|
||||
{:ok, %{session_params: session_params, url: url}} <- Assent.authorize_url(config) do
|
||||
conn
|
||||
|> put_session(session_key, session_params)
|
||||
|> put_resp_header("location", url)
|
||||
|> send_resp(:found, "Redirecting to #{strategy.name}")
|
||||
else
|
||||
{:error, reason} -> store_authentication_result(conn, {:error, reason})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Perform the callback phase of OAuth2.
|
||||
|
||||
Responds to a user being redirected back from the remote authentication
|
||||
provider, and validates the passed options, ultimately registering or
|
||||
signing-in a user if the authentication was successful.
|
||||
"""
|
||||
@spec callback(Conn.t(), OAuth2.t()) :: Conn.t()
|
||||
def callback(conn, strategy) do
|
||||
with {:ok, session_key} <- session_key(strategy),
|
||||
{:ok, config} <- config_for(strategy),
|
||||
session_params when is_map(session_params) <- get_session(conn, session_key),
|
||||
conn <- delete_session(conn, session_key),
|
||||
config <- Config.put(config, :session_params, session_params),
|
||||
{:ok, %{user: user, token: token}} <- Assent.callback(config, conn.params),
|
||||
{:ok, user} <-
|
||||
register_or_sign_in_user(strategy, %{user_info: user, oauth_tokens: token}) do
|
||||
store_authentication_result(conn, {:ok, user})
|
||||
else
|
||||
nil -> store_authentication_result(conn, {:error, nil})
|
||||
{:error, reason} -> store_authentication_result(conn, {:error, reason})
|
||||
end
|
||||
end
|
||||
|
||||
defp config_for(strategy) do
|
||||
with {:ok, client_id} <- fetch_secret(strategy, :client_id),
|
||||
{:ok, site} <- fetch_secret(strategy, :site),
|
||||
{:ok, redirect_uri} <- build_redirect_uri(strategy),
|
||||
{:ok, authorize_url} <- build_uri(strategy, :authorize_path),
|
||||
{:ok, token_url} <- build_uri(strategy, :token_path),
|
||||
{:ok, user_url} <- build_uri(strategy, :user_path) do
|
||||
config =
|
||||
[
|
||||
auth_method: strategy.auth_method,
|
||||
client_id: client_id,
|
||||
client_secret: get_secret(strategy, :client_secret),
|
||||
private_key: get_secret(strategy, :private_key),
|
||||
jwt_algorithm: Info.authentication_tokens_signing_algorithm(strategy.resource),
|
||||
authorization_params: strategy.authorization_params,
|
||||
redirect_uri: redirect_uri,
|
||||
site: site,
|
||||
authorize_url: authorize_url,
|
||||
token_url: token_url,
|
||||
user_url: user_url,
|
||||
http_adapter: Mint
|
||||
]
|
||||
|> Enum.reject(&is_nil(elem(&1, 1)))
|
||||
|
||||
{:ok, config}
|
||||
end
|
||||
end
|
||||
|
||||
defp register_or_sign_in_user(strategy, params) when strategy.registration_enabled?,
|
||||
do: Strategy.action(strategy, :register, params)
|
||||
|
||||
defp register_or_sign_in_user(strategy, params), do: Strategy.action(strategy, :sign_in, params)
|
||||
|
||||
# We need to temporarily store some information about the request in the
|
||||
# session so that we can verify that there hasn't been a CSRF-related attack.
|
||||
defp session_key(strategy) do
|
||||
case Info.authentication_subject_name(strategy.resource) do
|
||||
{:ok, subject_name} ->
|
||||
{:ok, "#{subject_name}/#{strategy.name}"}
|
||||
|
||||
:error ->
|
||||
{:error,
|
||||
AssumptionFailed.exception(
|
||||
message: "Resource `#{inspect(strategy.resource)}` has no subject name"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_secret(strategy, secret_name) do
|
||||
path = [:authentication, :strategies, strategy.name, secret_name]
|
||||
|
||||
with {:ok, {secret_module, secret_opts}} <- Map.fetch(strategy, secret_name),
|
||||
{:ok, secret} when is_binary(secret) and byte_size(secret) > 0 <-
|
||||
secret_module.secret_for(path, strategy.resource, secret_opts) do
|
||||
{:ok, secret}
|
||||
else
|
||||
{:ok, secret} when is_binary(secret) -> {:ok, secret}
|
||||
_ -> {:error, Errors.MissingSecret.exception(path: path, resource: strategy.resource)}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_secret(strategy, secret_name) do
|
||||
case fetch_secret(strategy, secret_name) do
|
||||
{:ok, secret} -> secret
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp build_redirect_uri(strategy) do
|
||||
with {:ok, subject_name} <- Info.authentication_subject_name(strategy.resource),
|
||||
{:ok, redirect_uri} <- fetch_secret(strategy, :redirect_uri),
|
||||
{:ok, uri} <- URI.new(redirect_uri) do
|
||||
path =
|
||||
Path.join([uri.path || "/", to_string(subject_name), to_string(strategy.name), "callback"])
|
||||
|
||||
{:ok, to_string(%URI{uri | path: path})}
|
||||
else
|
||||
:error ->
|
||||
{:error,
|
||||
AssumptionFailed.exception(
|
||||
message: "Resource `#{inspect(strategy.resource)}` has no subject name"
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_uri(strategy, secret_name) do
|
||||
with {:ok, site} <- fetch_secret(strategy, :site),
|
||||
{:ok, uri} <- URI.new(site),
|
||||
{:ok, path} <- fetch_secret(strategy, secret_name) do
|
||||
path = Path.join(uri.path || "/", path)
|
||||
|
||||
{:ok, to_string(%URI{uri | path: path})}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,70 @@
|
|||
defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
|
||||
@moduledoc """
|
||||
Prepare a query for sign in
|
||||
|
||||
Performs three main tasks:
|
||||
|
||||
1. Ensures that there is only one matching user record returned, otherwise
|
||||
returns an authentication failed error.
|
||||
2. Generates an access token if token generation is enabled.
|
||||
3. Updates the user identity resource, if one is enabled.
|
||||
"""
|
||||
use Ash.Resource.Preparation
|
||||
alias Ash.{Error.Framework.AssumptionFailed, Query, Resource.Preparation}
|
||||
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, UserIdentity}
|
||||
require Ash.Query
|
||||
import AshAuthentication.Utils, only: [is_falsy: 1]
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
|
||||
def prepare(query, _opts, _context) do
|
||||
case Map.fetch(query.context, :strategy) do
|
||||
:error ->
|
||||
{:error,
|
||||
AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")}
|
||||
|
||||
{:ok, strategy} ->
|
||||
query
|
||||
|> Query.after_action(fn
|
||||
query, [user] ->
|
||||
with {:ok, user} <- maybe_update_identity(user, query, strategy) do
|
||||
{:ok, [maybe_generate_token(user)]}
|
||||
end
|
||||
|
||||
_, _ ->
|
||||
{:error, AuthenticationFailed.exception(query: query)}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_update_identity(user, _query, strategy) when is_falsy(strategy.identity_resource),
|
||||
do: user
|
||||
|
||||
defp maybe_update_identity(user, query, strategy) do
|
||||
strategy.identity_resource
|
||||
|> UserIdentity.Actions.upsert(%{
|
||||
user_info: Query.get_argument(query, :user_info),
|
||||
oauth_tokens: Query.get_argument(query, :oauth_tokens),
|
||||
strategy: strategy.name,
|
||||
user_id: user.id
|
||||
})
|
||||
|> case do
|
||||
{:ok, _identity} ->
|
||||
user
|
||||
|> query.api.load(strategy.identity_relationship_name)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_generate_token(user) do
|
||||
if AshAuthentication.Info.authentication_tokens_enabled?(user.__struct__) do
|
||||
{:ok, token, _claims} = Jwt.token_for_user(user)
|
||||
%{user | __metadata__: Map.put(user.__metadata__, :token, token)}
|
||||
else
|
||||
user
|
||||
end
|
||||
end
|
||||
end
|
62
lib/ash_authentication/strategies/oauth2/strategy.ex
Normal file
62
lib/ash_authentication/strategies/oauth2/strategy.ex
Normal file
|
@ -0,0 +1,62 @@
|
|||
defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.OAuth2 do
|
||||
@moduledoc """
|
||||
Implmentation of `AshAuthentication.Strategy` for
|
||||
`AshAuthentication.Strategy.OAuth2`.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.{Info, Strategy, Strategy.OAuth2}
|
||||
alias Plug.Conn
|
||||
|
||||
@typedoc "The request phases supported by this strategy"
|
||||
@type phase :: :request | :callback
|
||||
|
||||
@typedoc "The actions supported by this strategy"
|
||||
@type action :: :register | :sign_in
|
||||
|
||||
@doc false
|
||||
@spec phases(OAuth2.t()) :: [phase]
|
||||
def phases(_), do: [:request, :callback]
|
||||
|
||||
@doc false
|
||||
@spec actions(OAuth2.t()) :: [action]
|
||||
def actions(%OAuth2{registration_enabled?: true}), do: [:register]
|
||||
def actions(%OAuth2{registration_enabled?: false}), do: [:sign_in]
|
||||
|
||||
@doc false
|
||||
@spec method_for_phase(OAuth2.t(), phase) :: Strategy.http_method()
|
||||
def method_for_phase(_, :request), do: :get
|
||||
def method_for_phase(_, :callback), do: :post
|
||||
|
||||
@doc """
|
||||
Return a list of routes for use by the strategy.
|
||||
"""
|
||||
@spec routes(OAuth2.t()) :: [Strategy.route()]
|
||||
def routes(strategy) do
|
||||
subject_name = Info.authentication_subject_name!(strategy.resource)
|
||||
|
||||
[request: nil, callback: :callback]
|
||||
|> Enum.map(fn {phase, suffix} ->
|
||||
path =
|
||||
[subject_name, strategy.name, suffix]
|
||||
|> Enum.map(&to_string/1)
|
||||
|> Path.join()
|
||||
|
||||
{"/#{path}", phase}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handle HTTP requests.
|
||||
"""
|
||||
@spec plug(OAuth2.t(), phase, Conn.t()) :: Conn.t()
|
||||
def plug(strategy, :request, conn), do: OAuth2.Plug.request(conn, strategy)
|
||||
def plug(strategy, :callback, conn), do: OAuth2.Plug.callback(conn, strategy)
|
||||
|
||||
@doc """
|
||||
Perform actions.
|
||||
"""
|
||||
@spec action(OAuth2.t(), action, map) :: {:ok, Resource.record()} | {:error, any}
|
||||
def action(strategy, :register, params), do: OAuth2.Actions.register(strategy, params)
|
||||
def action(strategy, :sign_in, params), do: OAuth2.Actions.sign_in(strategy, params)
|
||||
end
|
173
lib/ash_authentication/strategies/oauth2/transformer.ex
Normal file
173
lib/ash_authentication/strategies/oauth2/transformer.ex
Normal file
|
@ -0,0 +1,173 @@
|
|||
defmodule AshAuthentication.Strategy.OAuth2.Transformer do
|
||||
@moduledoc """
|
||||
DSL transformer for oauth2 strategies.
|
||||
|
||||
Iterates through any oauth2 strategies and ensures that all the correct
|
||||
actions and settings are in place.
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
alias Ash.{Resource, Type}
|
||||
alias AshAuthentication.{GenerateTokenChange, Info, Strategy.OAuth2}
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
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
|
||||
with strategy <- set_defaults(strategy),
|
||||
{:ok, dsl_state} <- maybe_build_identity_relationship(dsl_state, strategy),
|
||||
:ok <- maybe_validate_register_action(dsl_state, strategy),
|
||||
:ok <- maybe_validate_sign_in_action(dsl_state, strategy),
|
||||
{:ok, resource} <- persisted_option(dsl_state, :module) do
|
||||
dsl_state =
|
||||
dsl_state
|
||||
|> Transformer.replace_entity(
|
||||
[:authentication, :strategies],
|
||||
%{strategy | resource: resource},
|
||||
&(&1.name == strategy.name)
|
||||
)
|
||||
|
||||
{:ok, dsl_state}
|
||||
else
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:error,
|
||||
DslError.exception(path: [:authentication, :strategies, strategy.name], message: reason)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp set_defaults(strategy) do
|
||||
default_secret = {OAuth2.Default, []}
|
||||
|
||||
strategy
|
||||
|> maybe_set_field(:authorize_path, default_secret)
|
||||
|> maybe_set_field(:token_path, default_secret)
|
||||
|> maybe_set_field(:user_path, default_secret)
|
||||
|> maybe_set_field_lazy(:register_action_name, &:"register_with_#{&1.name}")
|
||||
|> maybe_set_field_lazy(:sign_in_action_name, &:"sign_in_with_#{&1.name}")
|
||||
end
|
||||
|
||||
defp maybe_build_identity_relationship(dsl_state, strategy)
|
||||
when is_falsy(strategy.identity_resource),
|
||||
do: {:ok, dsl_state}
|
||||
|
||||
defp maybe_build_identity_relationship(dsl_state, strategy) do
|
||||
maybe_build_relationship(
|
||||
dsl_state,
|
||||
strategy.identity_relationship_name,
|
||||
&build_identity_relationship(&1, strategy)
|
||||
)
|
||||
end
|
||||
|
||||
defp build_identity_relationship(_dsl_state, strategy) do
|
||||
Transformer.build_entity(Resource.Dsl, [:relationships], :has_many,
|
||||
name: strategy.identity_relationship_name,
|
||||
destination: strategy.identity_resource,
|
||||
destination_attribute: strategy.identity_relationship_user_id_attribute
|
||||
)
|
||||
end
|
||||
|
||||
defp maybe_validate_register_action(dsl_state, strategy) when strategy.registration_enabled? do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, strategy.register_action_name),
|
||||
:ok <- validate_action_has_argument(action, :user_info),
|
||||
:ok <- validate_action_argument_option(action, :user_info, :type, [Type.Map, :map]),
|
||||
:ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]),
|
||||
:ok <- validate_action_has_argument(action, :oauth_tokens),
|
||||
:ok <-
|
||||
validate_action_argument_option(action, :oauth_tokens, :type, [Type.Map, :map]),
|
||||
:ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]),
|
||||
:ok <- maybe_validate_action_has_token_change(dsl_state, action),
|
||||
:ok <- validate_field_in_values(action, :upsert?, [true]),
|
||||
:ok <-
|
||||
validate_field_with(
|
||||
action,
|
||||
:upsert_identity,
|
||||
&(is_atom(&1) and not is_falsy(&1)),
|
||||
"Expected `upsert_identity` to be set"
|
||||
),
|
||||
:ok <- maybe_validate_action_has_identity_change(action, strategy) do
|
||||
:ok
|
||||
else
|
||||
:error ->
|
||||
{:error, "Unable to validate register action"}
|
||||
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:error, "`#{inspect(strategy.register_action_name)}` action: #{reason}"}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_validate_register_action(_dsl_state, _strategy), do: :ok
|
||||
|
||||
defp maybe_validate_action_has_token_change(dsl_state, action) do
|
||||
if Info.authentication_tokens_enabled?(dsl_state) do
|
||||
validate_action_has_change(action, GenerateTokenChange)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_validate_action_has_identity_change(_action, strategy)
|
||||
when is_falsy(strategy.identity_resource),
|
||||
do: :ok
|
||||
|
||||
defp maybe_validate_action_has_identity_change(action, _strategy),
|
||||
do: validate_action_has_change(action, OAuth2.IdentityChange)
|
||||
|
||||
defp maybe_validate_sign_in_action(_dsl_state, strategy) when strategy.registration_enabled?,
|
||||
do: :ok
|
||||
|
||||
defp maybe_validate_sign_in_action(dsl_state, strategy) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, strategy.sign_in_action_name),
|
||||
:ok <- validate_action_has_argument(action, :user_info),
|
||||
:ok <- validate_action_argument_option(action, :user_info, :type, [Ash.Type.Map, :map]),
|
||||
:ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]),
|
||||
:ok <- validate_action_has_argument(action, :oauth_tokens),
|
||||
:ok <-
|
||||
validate_action_argument_option(action, :oauth_tokens, :type, [Ash.Type.Map, :map]),
|
||||
:ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]),
|
||||
:ok <- validate_action_has_preparation(action, OAuth2.SignInPreparation) do
|
||||
:ok
|
||||
else
|
||||
:error -> {:error, "Unable to validate sign in action"}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
148
lib/ash_authentication/strategies/password.ex
Normal file
148
lib/ash_authentication/strategies/password.ex
Normal file
|
@ -0,0 +1,148 @@
|
|||
defmodule AshAuthentication.Strategy.Password do
|
||||
import AshAuthentication.Dsl
|
||||
|
||||
@moduledoc """
|
||||
Strategy for authenticating using local resources as the source of truth.
|
||||
|
||||
In order to use password authentication your resource needs to meet the
|
||||
following minimum requirements:
|
||||
|
||||
1. Have a primary key.
|
||||
2. A uniquely constrained identity field (eg `username` or `email`).
|
||||
3. A sensitive string field within which to store the hashed password.
|
||||
|
||||
There are other options documented in the DSL.
|
||||
|
||||
### Example:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.User do
|
||||
use Ash.Resource,
|
||||
extensions: [AshAuthentication]
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :email, :ci_string, allow_nil?: false
|
||||
attribute :hashed_password, :string, allow_nil?: false, sensitive?: true
|
||||
end
|
||||
|
||||
authentication do
|
||||
api MyApp.Accounts
|
||||
|
||||
strategies do
|
||||
password do
|
||||
identity_field :email
|
||||
hashed_password_field :hashed_password
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_email, [:email]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Actions
|
||||
|
||||
By default the password strategy will automatically generate the register,
|
||||
sign-in, reset-request and reset actions for you, however you're free to
|
||||
define them yourself. If you do, then the action will be validated to ensure
|
||||
that all the needed configuration is present.
|
||||
|
||||
If you wish to work with the actions directly from your code you can do so via
|
||||
the `AshAuthentication.Strategy` protocol.
|
||||
|
||||
### Examples:
|
||||
|
||||
Interacting with the actions directly:
|
||||
|
||||
iex> strategy = Info.strategy!(Example.User, :password)
|
||||
...> {:ok, marty} = Strategy.action(strategy, :register, %{"username" => "marty", "password" => "outatime1985", "password_confirmation" => "outatime1985"})
|
||||
...> marty.username |> to_string()
|
||||
"marty"
|
||||
|
||||
...> {:ok, user} = Strategy.action(strategy, :sign_in, %{"username" => "outatime1985", "password" => "outatime1985"})
|
||||
...> user.username |> to_string()
|
||||
"marty"
|
||||
|
||||
## Plugs
|
||||
|
||||
The password strategy provides plug endpoints for all four actions, although
|
||||
only sign-in and register will be reported by `Strategy.routes/1` if the
|
||||
strategy is not configured as resettable.
|
||||
|
||||
If you wish to work with the plugs directly, you can do so via the
|
||||
`AshAuthentication.Strategy` protocol.
|
||||
|
||||
### Examples:
|
||||
|
||||
Dispatching to plugs directly:
|
||||
|
||||
iex> strategy = Info.strategy!(Example.User, :password)
|
||||
...> conn = conn(:post, "/user/password/register", %{"user" => %{"username" => "marty", "password" => "outatime1985", "password_confirmation" => "outatime1985"}})
|
||||
...> conn = Strategy.plug(strategy, :register, conn)
|
||||
...> {_conn, {:ok, marty}} = Plug.Helpers.get_authentication_result(conn)
|
||||
...> marty.username |> to_string()
|
||||
"marty"
|
||||
|
||||
...> conn = conn(:post, "/user/password/reset_request", %{"user" => %{"username" => "marty"}})
|
||||
...> conn = Strategy.plug(strategy, :reset_request, conn)
|
||||
...> {_conn, :ok} = Plug.Helpers.get_authentication_result(conn)
|
||||
|
||||
## DSL Documentation
|
||||
|
||||
#{Spark.Dsl.Extension.doc_entity(strategy(:password))}
|
||||
"""
|
||||
|
||||
defstruct identity_field: :username,
|
||||
hashed_password_field: :hashed_password_field,
|
||||
hash_provider: AshAuthentication.BcryptProvider,
|
||||
confirmation_required?: false,
|
||||
password_field: :password,
|
||||
password_confirmation_field: :password_confirmation,
|
||||
register_action_name: nil,
|
||||
sign_in_action_name: nil,
|
||||
resettable: [],
|
||||
name: nil,
|
||||
provider: :password,
|
||||
resource: nil
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.{Jwt, Strategy.Password}
|
||||
|
||||
@type t :: %Password{
|
||||
identity_field: atom,
|
||||
hashed_password_field: atom,
|
||||
hash_provider: module,
|
||||
confirmation_required?: boolean,
|
||||
password_field: atom,
|
||||
password_confirmation_field: atom,
|
||||
register_action_name: atom,
|
||||
sign_in_action_name: atom,
|
||||
resettable: [Password.Resettable.t()],
|
||||
name: atom,
|
||||
provider: atom,
|
||||
resource: module
|
||||
}
|
||||
|
||||
@doc """
|
||||
Generate a reset token for a user.
|
||||
|
||||
Used by `AshAuthentication.Strategy.Password.RequestPasswordResetPreparation`.
|
||||
"""
|
||||
@spec reset_token_for(t(), Resource.record()) :: {:ok, String.t()} | :error
|
||||
def reset_token_for(
|
||||
%Password{resettable: [%Password.Resettable{} = resettable]} = _strategy,
|
||||
user
|
||||
) do
|
||||
case Jwt.token_for_user(user, %{"act" => resettable.password_reset_action_name},
|
||||
token_lifetime: resettable.token_lifetime * 3600
|
||||
) do
|
||||
{:ok, token, _claims} -> {:ok, token}
|
||||
:error -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def reset_token_for(_strategy, _user), do: :error
|
||||
end
|
94
lib/ash_authentication/strategies/password/actions.ex
Normal file
94
lib/ash_authentication/strategies/password/actions.ex
Normal file
|
@ -0,0 +1,94 @@
|
|||
defmodule AshAuthentication.Strategy.Password.Actions do
|
||||
@moduledoc """
|
||||
Actions for the password strategy
|
||||
|
||||
Provides the code interface for working with resources via a password
|
||||
strategy.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Error.Invalid.NoSuchAction, Query, Resource}
|
||||
alias AshAuthentication.{Errors, Info, Jwt, Strategy.Password}
|
||||
|
||||
@doc """
|
||||
Attempt to sign in a user.
|
||||
"""
|
||||
@spec sign_in(Password.t(), map) ::
|
||||
{:ok, Resource.record()} | {:error, Errors.AuthenticationFailed.t()}
|
||||
def sign_in(%Password{} = strategy, params) do
|
||||
api = Info.authentication_api!(strategy.resource)
|
||||
|
||||
strategy.resource
|
||||
|> Query.new()
|
||||
|> Query.set_context(%{strategy: strategy})
|
||||
|> Query.for_read(strategy.sign_in_action_name, params)
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, [user]} -> {:ok, user}
|
||||
_ -> {:error, Errors.AuthenticationFailed.exception([])}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempt to register a new user.
|
||||
"""
|
||||
@spec register(Password.t(), map) :: {:ok, Resource.record()} | {:error, any}
|
||||
def register(%Password{} = strategy, params) do
|
||||
api = Info.authentication_api!(strategy.resource)
|
||||
|
||||
strategy.resource
|
||||
|> Changeset.new()
|
||||
|> Changeset.set_context(%{strategy: strategy})
|
||||
|> Changeset.for_create(strategy.register_action_name, params)
|
||||
|> api.create()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Request a password reset.
|
||||
"""
|
||||
@spec reset_request(Password.t(), map) :: :ok | {:error, any}
|
||||
def reset_request(
|
||||
%Password{resettable: [%Password.Resettable{} = resettable]} = strategy,
|
||||
params
|
||||
) do
|
||||
api = Info.authentication_api!(strategy.resource)
|
||||
|
||||
strategy.resource
|
||||
|> Query.new()
|
||||
|> Query.set_context(%{strategy: strategy})
|
||||
|> Query.for_read(resettable.request_password_reset_action_name, params)
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, _} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def reset_request(%Password{} = strategy, _params),
|
||||
do:
|
||||
{:error,
|
||||
NoSuchAction.exception(resource: strategy.resource, action: :reset_request, type: :read)}
|
||||
|
||||
@doc """
|
||||
Attempt to change a user's password using a reset token.
|
||||
"""
|
||||
@spec reset(Password.t(), map) :: {:ok, Resource.record()} | {:error, any}
|
||||
def reset(%Password{resettable: [%Password.Resettable{} = resettable]} = strategy, params) do
|
||||
with {:ok, token} <- Map.fetch(params, "reset_token"),
|
||||
{:ok, %{"sub" => subject}, resource} <- Jwt.verify(token, strategy.resource),
|
||||
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do
|
||||
api = Info.authentication_api!(resource)
|
||||
|
||||
user
|
||||
|> Changeset.new()
|
||||
|> Changeset.set_context(%{strategy: strategy})
|
||||
|> Changeset.for_update(resettable.password_reset_action_name, params)
|
||||
|> api.update()
|
||||
else
|
||||
{:error, %Changeset{} = changeset} -> {:error, changeset}
|
||||
_ -> {:error, Errors.InvalidToken.exception(type: :reset)}
|
||||
end
|
||||
end
|
||||
|
||||
def reset(%Password{} = strategy, _params),
|
||||
do: {:error, NoSuchAction.exception(resource: strategy.resource, action: :reset, type: :read)}
|
||||
end
|
|
@ -0,0 +1,32 @@
|
|||
defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
|
||||
@moduledoc """
|
||||
Set the hash based on the password input.
|
||||
|
||||
Uses the configured `AshAuthentication.HashProvider` to generate a hash of the
|
||||
user's password input and store it in the changeset.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _) do
|
||||
changeset
|
||||
|> Changeset.before_action(fn changeset ->
|
||||
with {:ok, strategy} <- Map.fetch(changeset.context, :strategy),
|
||||
value when is_binary(value) <-
|
||||
Changeset.get_argument(changeset, strategy.password_field),
|
||||
{:ok, hash} <- strategy.hash_provider.hash(value) do
|
||||
Changeset.change_attribute(changeset, strategy.hashed_password_field, hash)
|
||||
else
|
||||
:error ->
|
||||
raise AssumptionFailed, message: "Error hashing password."
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,45 @@
|
|||
defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do
|
||||
@moduledoc """
|
||||
Validate that the password and password confirmation match.
|
||||
|
||||
This check is only performed when the `confirmation_required?` DSL option is set to `true`.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Validation
|
||||
alias Ash.{Changeset, Error.Changes.InvalidArgument, Error.Framework.AssumptionFailed}
|
||||
|
||||
@doc """
|
||||
Validates that the password and password confirmation fields contain
|
||||
equivalent values - if confirmation is required.
|
||||
"""
|
||||
@impl true
|
||||
@spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()}
|
||||
def validate(changeset, _) do
|
||||
case Map.fetch(changeset.context, :strategy) do
|
||||
{:ok, %{confirmation_required?: true} = strategy} ->
|
||||
validate_password_confirmation(changeset, strategy)
|
||||
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
:error ->
|
||||
{:error,
|
||||
AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_password_confirmation(changeset, strategy) do
|
||||
password = Changeset.get_argument(changeset, strategy.password_field)
|
||||
confirmation = Changeset.get_argument(changeset, strategy.password_confirmation_field)
|
||||
|
||||
if password == confirmation do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
InvalidArgument.exception(
|
||||
field: strategy.password_confirmation_field,
|
||||
message: "does not match"
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
80
lib/ash_authentication/strategies/password/plug.ex
Normal file
80
lib/ash_authentication/strategies/password/plug.ex
Normal file
|
@ -0,0 +1,80 @@
|
|||
defmodule AshAuthentication.Strategy.Password.Plug do
|
||||
@moduledoc """
|
||||
Plugs for the password strategy.
|
||||
|
||||
Handles registration, sign-in and password resets.
|
||||
"""
|
||||
|
||||
alias AshAuthentication.{Info, Strategy, Strategy.Password}
|
||||
alias Plug.Conn
|
||||
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
|
||||
|
||||
@doc "Handle a registration request"
|
||||
@spec register(Conn.t(), Password.t()) :: Conn.t()
|
||||
def register(conn, strategy) do
|
||||
params =
|
||||
conn
|
||||
|> subject_params(strategy)
|
||||
|
||||
result =
|
||||
strategy
|
||||
|> Strategy.action(:register, params)
|
||||
|
||||
conn
|
||||
|> store_authentication_result(result)
|
||||
end
|
||||
|
||||
@doc "Handle a sign-in request"
|
||||
@spec sign_in(Conn.t(), Password.t()) :: Conn.t()
|
||||
def sign_in(conn, strategy) do
|
||||
params =
|
||||
conn
|
||||
|> subject_params(strategy)
|
||||
|
||||
result =
|
||||
strategy
|
||||
|> Strategy.action(:sign_in, params)
|
||||
|
||||
conn
|
||||
|> store_authentication_result(result)
|
||||
end
|
||||
|
||||
@doc "Handle a reset request request"
|
||||
@spec reset_request(Conn.t(), Password.t()) :: Conn.t()
|
||||
def reset_request(conn, strategy) do
|
||||
params =
|
||||
conn
|
||||
|> subject_params(strategy)
|
||||
|
||||
result =
|
||||
strategy
|
||||
|> Strategy.action(:reset_request, params)
|
||||
|
||||
conn
|
||||
|> store_authentication_result(result)
|
||||
end
|
||||
|
||||
@doc "Handle a reset request"
|
||||
@spec reset(Conn.t(), Password.t()) :: Conn.t()
|
||||
def reset(conn, strategy) do
|
||||
params =
|
||||
conn
|
||||
|> subject_params(strategy)
|
||||
|
||||
result =
|
||||
strategy
|
||||
|> Strategy.action(:reset, params)
|
||||
|
||||
conn
|
||||
|> store_authentication_result(result)
|
||||
end
|
||||
|
||||
defp subject_params(conn, strategy) do
|
||||
subject_name =
|
||||
strategy.resource
|
||||
|> Info.authentication_subject_name!()
|
||||
|> to_string()
|
||||
|
||||
Map.get(conn.params, subject_name, %{})
|
||||
end
|
||||
end
|
|
@ -0,0 +1,44 @@
|
|||
defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do
|
||||
@moduledoc """
|
||||
Prepare a query for a password reset request.
|
||||
This preparation performs three jobs, one before the query executes and two
|
||||
after.
|
||||
Firstly, it constraints the query to match the identity field passed to the
|
||||
action.
|
||||
Secondly, if there is a user returned by the query, then generate a reset
|
||||
token and publish a notification. Always returns an empty result.
|
||||
"""
|
||||
use Ash.Resource.Preparation
|
||||
alias Ash.{Query, Resource.Preparation}
|
||||
alias AshAuthentication.Strategy.Password
|
||||
require Ash.Query
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
|
||||
def prepare(query, _opts, _context) do
|
||||
strategy = Map.fetch!(query.context, :strategy)
|
||||
|
||||
if Enum.any?(strategy.resettable) do
|
||||
identity_field = strategy.identity_field
|
||||
identity = Query.get_argument(query, identity_field)
|
||||
|
||||
query
|
||||
|> Query.filter(ref(^identity_field) == ^identity)
|
||||
|> Query.after_action(&after_action(&1, &2, strategy))
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
defp after_action(_query, [user], %{resettable: [%{sender: {sender, send_opts}}]} = strategy) do
|
||||
case Password.reset_token_for(strategy, user) do
|
||||
{:ok, token} -> sender.send(user, token, send_opts)
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
{:ok, []}
|
||||
end
|
||||
|
||||
defp after_action(_query, _, _), do: {:ok, []}
|
||||
end
|
|
@ -1,20 +1,21 @@
|
|||
defmodule AshAuthentication.PasswordReset.ResetTokenValidation do
|
||||
defmodule AshAuthentication.Strategy.Password.ResetTokenValidation do
|
||||
@moduledoc """
|
||||
Validate that the token is a valid password reset request token.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Validation
|
||||
alias Ash.{Changeset, Error.Changes.InvalidArgument}
|
||||
alias AshAuthentication.{Jwt, PasswordReset.Info}
|
||||
alias AshAuthentication.Jwt
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec validate(Changeset.t(), keyword) :: :ok | {:error, Exception.t()}
|
||||
def validate(changeset, _) do
|
||||
with token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token),
|
||||
with {:ok, strategy} <- Map.fetch(changeset.context, :strategy),
|
||||
token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token),
|
||||
{:ok, %{"act" => token_action}, _} <- Jwt.verify(token, changeset.resource),
|
||||
{:ok, resource_action} <- Info.password_reset_action_name(changeset.resource),
|
||||
true <- to_string(resource_action) == token_action do
|
||||
{:ok, [resettable]} <- Map.fetch(strategy, :resettable),
|
||||
true <- to_string(resettable.password_reset_action_name) == token_action do
|
||||
:ok
|
||||
else
|
||||
_ ->
|
76
lib/ash_authentication/strategies/password/resettable.ex
Normal file
76
lib/ash_authentication/strategies/password/resettable.ex
Normal file
|
@ -0,0 +1,76 @@
|
|||
defmodule AshAuthentication.Strategy.Password.Resettable do
|
||||
@moduledoc """
|
||||
The entity used to store password reset information.
|
||||
"""
|
||||
|
||||
@default_lifetime_days 3
|
||||
|
||||
defstruct token_lifetime: @default_lifetime_days * 24,
|
||||
request_password_reset_action_name: nil,
|
||||
password_reset_action_name: nil,
|
||||
sender: nil
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
token_lifetime: hours :: pos_integer,
|
||||
request_password_reset_action_name: atom,
|
||||
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, 2}},
|
||||
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
|
|
@ -1,4 +1,4 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do
|
||||
defmodule AshAuthentication.Strategy.Password.SignInPreparation do
|
||||
@moduledoc """
|
||||
Prepare a query for sign in
|
||||
|
||||
|
@ -13,32 +13,30 @@ defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do
|
|||
an authentication failed error.
|
||||
"""
|
||||
use Ash.Resource.Preparation
|
||||
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, PasswordAuthentication.Info}
|
||||
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt}
|
||||
alias Ash.{Query, Resource.Preparation}
|
||||
require Ash.Query
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
|
||||
def prepare(query, _opts, _) do
|
||||
{:ok, identity_field} = Info.password_authentication_identity_field(query.resource)
|
||||
{:ok, password_field} = Info.password_authentication_password_field(query.resource)
|
||||
{:ok, hasher} = Info.password_authentication_hash_provider(query.resource)
|
||||
|
||||
def prepare(query, _opts, _context) do
|
||||
strategy = Map.fetch!(query.context, :strategy)
|
||||
identity_field = strategy.identity_field
|
||||
identity = Query.get_argument(query, identity_field)
|
||||
|
||||
query
|
||||
|> Query.filter(ref(^identity_field) == ^identity)
|
||||
|> Query.after_action(fn
|
||||
query, [record] ->
|
||||
password = Query.get_argument(query, password_field)
|
||||
password = Query.get_argument(query, strategy.password_field)
|
||||
|
||||
if hasher.valid?(password, record.hashed_password),
|
||||
if strategy.hash_provider.valid?(password, record.hashed_password),
|
||||
do: {:ok, [maybe_generate_token(record)]},
|
||||
else: auth_failed(query)
|
||||
|
||||
_, _ ->
|
||||
hasher.simulate()
|
||||
strategy.hash_provider.simulate()
|
||||
auth_failed(query)
|
||||
end)
|
||||
end
|
||||
|
@ -46,8 +44,8 @@ defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do
|
|||
defp auth_failed(query), do: {:error, AuthenticationFailed.exception(query: query)}
|
||||
|
||||
defp maybe_generate_token(record) do
|
||||
if AshAuthentication.Info.tokens_enabled?(record.__struct__) do
|
||||
{:ok, token, _claims} = Jwt.token_for_record(record)
|
||||
if AshAuthentication.Info.authentication_tokens_enabled?(record.__struct__) do
|
||||
{:ok, token, _claims} = Jwt.token_for_user(record)
|
||||
%{record | __metadata__: Map.put(record.__metadata__, :token, token)}
|
||||
else
|
||||
record
|
73
lib/ash_authentication/strategies/password/strategy.ex
Normal file
73
lib/ash_authentication/strategies/password/strategy.ex
Normal file
|
@ -0,0 +1,73 @@
|
|||
defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Password do
|
||||
@moduledoc """
|
||||
Implementation of `AshAuthentication.Strategy` for
|
||||
`AshAuthentication.Strategy.Password`.
|
||||
|
||||
Because the password strategy can optionally provide password reset
|
||||
functionality it provides more than the usual number of routes, actions, etc.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.{Info, Strategy, Strategy.Password}
|
||||
alias Plug.Conn
|
||||
|
||||
@typedoc """
|
||||
The possible request phases for the password strategy.
|
||||
|
||||
Only the first two will be used if password resets are disabled.
|
||||
"""
|
||||
@type phase :: :register | :sign_in | :reset_request | :reset
|
||||
|
||||
@doc false
|
||||
@spec phases(Password.t()) :: [phase]
|
||||
def phases(%{resettable: []}), do: [:register, :sign_in]
|
||||
def phases(_strategy), do: [:register, :sign_in, :reset_request, :reset]
|
||||
|
||||
@doc false
|
||||
@spec actions(Password.t()) :: [phase]
|
||||
def actions(strategy), do: phases(strategy)
|
||||
|
||||
@doc false
|
||||
@spec method_for_phase(Password.t(), phase) :: Strategy.http_method()
|
||||
def method_for_phase(_, _), do: :post
|
||||
|
||||
@doc """
|
||||
Return a list of routes for use by the strategy.
|
||||
"""
|
||||
@spec routes(Password.t()) :: [Strategy.route()]
|
||||
def routes(strategy) do
|
||||
subject_name = Info.authentication_subject_name!(strategy.resource)
|
||||
|
||||
strategy
|
||||
|> phases()
|
||||
|> Enum.map(fn phase ->
|
||||
path =
|
||||
[subject_name, strategy.name, phase]
|
||||
|> Enum.map(&to_string/1)
|
||||
|> Path.join()
|
||||
|
||||
{"/#{path}", phase}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handle HTTP requests.
|
||||
"""
|
||||
@spec plug(Password.t(), phase, Conn.t()) :: Conn.t()
|
||||
def plug(strategy, :register, conn), do: Password.Plug.register(conn, strategy)
|
||||
def plug(strategy, :sign_in, conn), do: Password.Plug.sign_in(conn, strategy)
|
||||
def plug(strategy, :reset_request, conn), do: Password.Plug.reset_request(conn, strategy)
|
||||
def plug(strategy, :reset, conn), do: Password.Plug.reset(conn, strategy)
|
||||
|
||||
@doc """
|
||||
Perform actions.
|
||||
"""
|
||||
@spec action(Password.t(), phase, map) :: {:ok, Resource.record()} | {:error, any}
|
||||
def action(strategy, :register, params), do: Password.Actions.register(strategy, params)
|
||||
def action(strategy, :sign_in, params), do: Password.Actions.sign_in(strategy, params)
|
||||
|
||||
def action(strategy, :reset_request, params),
|
||||
do: Password.Actions.reset_request(strategy, params)
|
||||
|
||||
def action(strategy, :reset, params), do: Password.Actions.reset(strategy, params)
|
||||
end
|
418
lib/ash_authentication/strategies/password/transformer.ex
Normal file
418
lib/ash_authentication/strategies/password/transformer.ex
Normal file
|
@ -0,0 +1,418 @@
|
|||
defmodule AshAuthentication.Strategy.Password.Transformer do
|
||||
@moduledoc """
|
||||
DSL transformer for the password strategy.
|
||||
|
||||
Iterates through any password authentication strategies and ensures that all
|
||||
the correct actions and settings are in place.
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
|
||||
alias Ash.{Resource, Type}
|
||||
alias AshAuthentication.{GenerateTokenChange, HashProvider, Info, Sender, Strategy.Password}
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
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
|
||||
with :ok <- validate_identity_field(strategy.identity_field, dsl_state),
|
||||
:ok <- validate_hashed_password_field(strategy.hashed_password_field, dsl_state),
|
||||
strategy <-
|
||||
maybe_set_field_lazy(strategy, :register_action_name, &:"register_with_#{&1.name}"),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
strategy.register_action_name,
|
||||
&build_register_action(&1, strategy)
|
||||
),
|
||||
:ok <- validate_register_action(dsl_state, strategy),
|
||||
strategy <-
|
||||
maybe_set_field_lazy(strategy, :sign_in_action_name, &:"sign_in_with_#{&1.name}"),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
strategy.sign_in_action_name,
|
||||
&build_sign_in_action(&1, strategy)
|
||||
),
|
||||
:ok <- validate_sign_in_action(dsl_state, strategy),
|
||||
:ok <- validate_behaviour(strategy.hash_provider, HashProvider),
|
||||
{:ok, dsl_state, strategy} <- maybe_transform_resettable(dsl_state, strategy),
|
||||
{:ok, resource} <- persisted_option(dsl_state, :module) do
|
||||
dsl_state =
|
||||
dsl_state
|
||||
|> Transformer.replace_entity(
|
||||
[:authentication, :strategies],
|
||||
%{strategy | resource: resource},
|
||||
&(&1.name == strategy.name)
|
||||
)
|
||||
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_identity_field(identity_field, dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, identity_field),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]) do
|
||||
validate_attribute_unique_constraint(dsl_state, [identity_field], resource)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_hashed_password_field(hashed_password_field, dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, hashed_password_field),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]) do
|
||||
validate_attribute_option(attribute, resource, :sensitive?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
defp build_register_action(_dsl_state, strategy) do
|
||||
password_opts = [
|
||||
type: Type.String,
|
||||
allow_nil?: false,
|
||||
constraints: [min_length: 8],
|
||||
sensitive?: true
|
||||
]
|
||||
|
||||
arguments =
|
||||
[
|
||||
Transformer.build_entity!(
|
||||
Resource.Dsl,
|
||||
[:actions, :create],
|
||||
:argument,
|
||||
Keyword.put(password_opts, :name, strategy.password_field)
|
||||
)
|
||||
]
|
||||
|> maybe_append(
|
||||
strategy.confirmation_required?,
|
||||
Transformer.build_entity!(
|
||||
Resource.Dsl,
|
||||
[:actions, :create],
|
||||
:argument,
|
||||
Keyword.put(password_opts, :name, strategy.password_confirmation_field)
|
||||
)
|
||||
)
|
||||
|
||||
changes =
|
||||
[]
|
||||
|> maybe_append(
|
||||
strategy.confirmation_required?,
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :validate,
|
||||
validation: Password.PasswordConfirmationValidation
|
||||
)
|
||||
)
|
||||
|> Enum.concat([
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
|
||||
change: Password.HashPasswordChange
|
||||
),
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
|
||||
change: GenerateTokenChange
|
||||
)
|
||||
])
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :create,
|
||||
name: strategy.register_action_name,
|
||||
arguments: arguments,
|
||||
changes: changes,
|
||||
allow_nil_input: [strategy.hashed_password_field]
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_register_action(dsl_state, strategy) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, strategy.register_action_name),
|
||||
:ok <- validate_allow_nil_input(action, strategy.hashed_password_field),
|
||||
:ok <- validate_password_argument(action, strategy.password_field, true),
|
||||
:ok <-
|
||||
validate_password_argument(
|
||||
action,
|
||||
strategy.password_confirmation_field,
|
||||
strategy.confirmation_required?
|
||||
),
|
||||
:ok <- validate_action_has_change(action, Password.HashPasswordChange),
|
||||
:ok <- validate_action_has_change(action, GenerateTokenChange) do
|
||||
validate_action_has_validation(
|
||||
action,
|
||||
Password.PasswordConfirmationValidation,
|
||||
strategy.confirmation_required?
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_allow_nil_input(action, field) do
|
||||
allowed_nil_fields = Map.get(action, :allow_nil_input, [])
|
||||
|
||||
if field in allowed_nil_fields do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :allow_nil_input],
|
||||
message:
|
||||
"Expected the action `#{inspect(action.name)}` to allow nil input for the field `#{inspect(field)}`"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_password_argument(action, field, true) do
|
||||
with :ok <- validate_action_argument_option(action, field, :type, [Ash.Type.String]) do
|
||||
validate_action_argument_option(action, field, :sensitive?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_password_argument(_action, _field, _), do: :ok
|
||||
|
||||
defp validate_action_has_validation(action, validation, true),
|
||||
do: validate_action_has_validation(action, validation)
|
||||
|
||||
defp validate_action_has_validation(_action, _validation, _), do: :ok
|
||||
|
||||
defp build_sign_in_action(dsl_state, strategy) do
|
||||
identity_attribute = Resource.Info.attribute(dsl_state, strategy.identity_field)
|
||||
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||
name: strategy.identity_field,
|
||||
type: identity_attribute.type,
|
||||
allow_nil?: false
|
||||
),
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||
name: strategy.password_field,
|
||||
type: Type.String,
|
||||
allow_nil?: false,
|
||||
sensitive?: true
|
||||
)
|
||||
]
|
||||
|
||||
preparations = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
|
||||
preparation: Password.SignInPreparation
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: strategy.sign_in_action_name,
|
||||
arguments: arguments,
|
||||
preparations: preparations,
|
||||
get?: true
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_sign_in_action(dsl_state, strategy) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, strategy.sign_in_action_name),
|
||||
:ok <- validate_identity_argument(dsl_state, action, strategy.identity_field),
|
||||
:ok <- validate_password_argument(action, strategy.password_field, true) do
|
||||
validate_action_has_preparation(action, Password.SignInPreparation)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_identity_argument(dsl_state, action, identity_field) do
|
||||
identity_attribute = Ash.Resource.Info.attribute(dsl_state, identity_field)
|
||||
validate_action_argument_option(action, identity_field, :type, [identity_attribute.type])
|
||||
end
|
||||
|
||||
defp maybe_transform_resettable(dsl_state, %{resettable: []} = strategy),
|
||||
do: {:ok, dsl_state, strategy}
|
||||
|
||||
defp maybe_transform_resettable(dsl_state, %{resettable: [resettable]} = strategy) do
|
||||
with {:ok, {sender, _opts}} <- Map.fetch(resettable, :sender),
|
||||
:ok <- validate_behaviour(sender, Sender),
|
||||
resettable <-
|
||||
maybe_set_field_lazy(
|
||||
resettable,
|
||||
:request_password_reset_action_name,
|
||||
fn _ -> :"request_password_reset_with_#{strategy.name}" end
|
||||
),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
resettable.request_password_reset_action_name,
|
||||
&build_reset_request_action(&1, resettable, strategy)
|
||||
),
|
||||
:ok <- validate_reset_request_action(dsl_state, resettable, strategy),
|
||||
resettable <-
|
||||
maybe_set_field_lazy(
|
||||
resettable,
|
||||
:password_reset_action_name,
|
||||
fn _ -> :"password_reset_with_#{strategy.name}" end
|
||||
),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
resettable.password_reset_action_name,
|
||||
&build_reset_action(&1, resettable, strategy)
|
||||
),
|
||||
:ok <- validate_reset_action(dsl_state, resettable, strategy) do
|
||||
{:ok, dsl_state, %{strategy | resettable: [resettable]}}
|
||||
else
|
||||
:error ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:authentication, :strategies, :password, :resettable],
|
||||
message: "A `sender` is required."
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_transform_resettable(_dsl_state, %{resettable: [_ | _]}),
|
||||
do:
|
||||
DslError.exception(
|
||||
path: [:authentication, :strategies, :password],
|
||||
message: "Only one `resettable` entity may be present."
|
||||
)
|
||||
|
||||
defp build_reset_request_action(dsl_state, resettable, strategy) do
|
||||
identity_attribute = Resource.Info.attribute(dsl_state, strategy.identity_field)
|
||||
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||
name: strategy.identity_field,
|
||||
type: identity_attribute.type,
|
||||
allow_nil?: false
|
||||
)
|
||||
]
|
||||
|
||||
preparations = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
|
||||
preparation: Password.RequestPasswordResetPreparation
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: resettable.request_password_reset_action_name,
|
||||
arguments: arguments,
|
||||
preparations: preparations
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_reset_request_action(dsl_state, resettable, strategy) do
|
||||
with {:ok, action} <-
|
||||
validate_action_exists(dsl_state, resettable.request_password_reset_action_name),
|
||||
:ok <- validate_identity_argument(dsl_state, action, strategy.identity_field) do
|
||||
validate_action_has_preparation(action, Password.RequestPasswordResetPreparation)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_reset_action(_dsl_state, resettable, strategy) do
|
||||
password_opts = [
|
||||
type: Type.String,
|
||||
allow_nil?: false,
|
||||
constraints: [min_length: 8],
|
||||
sensitive?: true
|
||||
]
|
||||
|
||||
arguments =
|
||||
[
|
||||
Transformer.build_entity!(
|
||||
Resource.Dsl,
|
||||
[:actions, :update],
|
||||
:argument,
|
||||
name: :reset_token,
|
||||
type: Type.String,
|
||||
sensitive?: true
|
||||
),
|
||||
Transformer.build_entity!(
|
||||
Resource.Dsl,
|
||||
[:actions, :update],
|
||||
:argument,
|
||||
Keyword.put(password_opts, :name, strategy.password_field)
|
||||
)
|
||||
]
|
||||
|> maybe_append(
|
||||
strategy.confirmation_required?,
|
||||
Transformer.build_entity!(
|
||||
Resource.Dsl,
|
||||
[:actions, :update],
|
||||
:argument,
|
||||
Keyword.put(password_opts, :name, strategy.password_confirmation_field)
|
||||
)
|
||||
)
|
||||
|
||||
changes =
|
||||
[
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate,
|
||||
validation: Password.ResetTokenValidation
|
||||
)
|
||||
]
|
||||
|> maybe_append(
|
||||
strategy.confirmation_required?,
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate,
|
||||
validation: Password.PasswordConfirmationValidation
|
||||
)
|
||||
)
|
||||
|> Enum.concat([
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
|
||||
change: Password.HashPasswordChange
|
||||
),
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
|
||||
change: GenerateTokenChange
|
||||
)
|
||||
])
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :update,
|
||||
name: resettable.password_reset_action_name,
|
||||
arguments: arguments,
|
||||
changes: changes,
|
||||
accept: []
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_reset_action(dsl_state, resettable, strategy) do
|
||||
with {:ok, action} <-
|
||||
validate_action_exists(dsl_state, resettable.password_reset_action_name),
|
||||
:ok <- validate_action_has_validation(action, Password.ResetTokenValidation),
|
||||
:ok <- validate_action_has_change(action, Password.HashPasswordChange),
|
||||
:ok <- validate_password_argument(action, strategy.password_field, true),
|
||||
:ok <-
|
||||
validate_password_argument(
|
||||
action,
|
||||
strategy.password_confirmation_field,
|
||||
strategy.confirmation_required?
|
||||
),
|
||||
:ok <-
|
||||
validate_action_has_validation(
|
||||
action,
|
||||
Password.PasswordConfirmationValidation,
|
||||
strategy.confirmation_required?
|
||||
) do
|
||||
validate_action_has_change(action, GenerateTokenChange)
|
||||
end
|
||||
end
|
||||
end
|
115
lib/ash_authentication/strategy.ex
Normal file
115
lib/ash_authentication/strategy.ex
Normal file
|
@ -0,0 +1,115 @@
|
|||
defprotocol AshAuthentication.Strategy do
|
||||
@moduledoc """
|
||||
The protocol used for interacting with authentication strategies.
|
||||
|
||||
Any new Authentication strategy must implement this protocol.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias Plug.Conn
|
||||
|
||||
@typedoc "A path to match in web requests"
|
||||
@type path :: String.t()
|
||||
|
||||
@typedoc """
|
||||
The \"phase\" of the request.
|
||||
|
||||
Usually `:request` or `:callback` but can be any atom.
|
||||
"""
|
||||
@type phase :: atom
|
||||
|
||||
@typedoc """
|
||||
The name of an individual action supported by the strategy.
|
||||
|
||||
This maybe not be the action name on the underlying resource, which may be
|
||||
generated, but the name that the strategy itself calls the action.
|
||||
"""
|
||||
@type action :: atom
|
||||
|
||||
@typedoc """
|
||||
An individual route.
|
||||
|
||||
Eg: `{"/user/password/sign_in", :sign_in}`
|
||||
"""
|
||||
@type route :: {path, phase}
|
||||
|
||||
@type http_method ::
|
||||
:get | :head | :post | :put | :delete | :connect | :options | :trace | :patch
|
||||
|
||||
@doc """
|
||||
Return a list of phases supported by the strategy.
|
||||
|
||||
## Example
|
||||
|
||||
iex> strategy = Info.strategy!(Example.User, :password)
|
||||
...> phases(strategy)
|
||||
[:register, :sign_in, :reset_request, :reset]
|
||||
"""
|
||||
@spec phases(t) :: [phase]
|
||||
def phases(strategy)
|
||||
|
||||
@doc """
|
||||
Return a list of actions supported by the strategy.
|
||||
|
||||
## Example
|
||||
|
||||
iex> strategy = Info.strategy!(Example.User, :password)
|
||||
...> actions(strategy)
|
||||
[:register, :sign_in, :reset_request, :reset]
|
||||
"""
|
||||
@spec actions(t) :: [action]
|
||||
def actions(strategy)
|
||||
|
||||
@doc """
|
||||
Used to build the routing table to route web requests to request phases for
|
||||
each strategy.
|
||||
|
||||
## Example
|
||||
|
||||
iex> strategy = Info.strategy!(Example.User, :password)
|
||||
...> routes(strategy)
|
||||
[
|
||||
{"/user/password/register", :register},
|
||||
{"/user/password/sign_in", :sign_in},
|
||||
{"/user/password/reset_request", :reset_request},
|
||||
{"/user/password/reset", :reset}
|
||||
]
|
||||
"""
|
||||
@spec routes(t) :: [route]
|
||||
def routes(strategy)
|
||||
|
||||
@doc """
|
||||
Return the HTTP method for a phase.
|
||||
|
||||
## Example
|
||||
|
||||
iex> strategy = Info.strategy!(Example.User, :oauth2)
|
||||
...> method_for_phase(strategy, :request)
|
||||
:get
|
||||
|
||||
"""
|
||||
@spec method_for_phase(t, phase) :: http_method
|
||||
def method_for_phase(t, phase)
|
||||
|
||||
@doc """
|
||||
Handle requests routed to the strategy.
|
||||
|
||||
Each phase will be an atom (ie the second element in the route tuple).
|
||||
|
||||
See `phases/1` for a list of phases supported by the strategy.
|
||||
"""
|
||||
@spec plug(t, phase, Conn.t()) :: Conn.t()
|
||||
def plug(strategy, phase, conn)
|
||||
|
||||
@doc """
|
||||
Perform an named action.
|
||||
|
||||
Different strategies are likely to implement a number of different actions
|
||||
depending on their configuration. Calling them via this function will ensure
|
||||
that the context is correctly set, etc.
|
||||
|
||||
See `actions/1` for a list of actions provided by the strategy.
|
||||
"""
|
||||
@spec action(t, action, params :: map) :: :ok | {:ok, Resource.record()} | {:error, any}
|
||||
def action(strategy, action_name, params)
|
||||
end
|
|
@ -18,9 +18,10 @@ defmodule AshAuthentication.TokenRevocation do
|
|||
@moduledoc """
|
||||
An Ash extension which generates the defaults for a token revocation resource.
|
||||
|
||||
The token revocation resource is used to store the Json Web Token ID an expiry
|
||||
times of any tokens which have been revoked. These will be removed once the
|
||||
expiry date has passed, so should only ever be a fairly small number of rows.
|
||||
The token revocation resource is used to store the Json Web Token ID (jti) and
|
||||
expiry times of any tokens which have been revoked. These will be removed
|
||||
once the expiry date has passed, so should only ever be a fairly small number
|
||||
of rows.
|
||||
|
||||
## Storage
|
||||
|
||||
|
@ -75,10 +76,17 @@ defmodule AshAuthentication.TokenRevocation do
|
|||
|
||||
@doc """
|
||||
Revoke a token.
|
||||
|
||||
## Example
|
||||
|
||||
iex> {token, _} = build_token()
|
||||
...> revoke(Example.TokenRevocation, token)
|
||||
:ok
|
||||
|
||||
"""
|
||||
@spec revoke(Resource.t(), token :: String.t()) :: :ok | {:error, any}
|
||||
def revoke(resource, token) do
|
||||
with {:ok, api} <- Info.api(resource) do
|
||||
with {:ok, api} <- Info.revocation_api(resource) do
|
||||
resource
|
||||
|> Changeset.for_create(:revoke_token, %{token: token})
|
||||
|> api.create(upsert?: true)
|
||||
|
@ -92,10 +100,19 @@ defmodule AshAuthentication.TokenRevocation do
|
|||
|
||||
@doc """
|
||||
Find out if (via it's JTI) a token has been revoked?
|
||||
|
||||
## Example
|
||||
|
||||
iex> {token, %{"jti" => jti}} = build_token()
|
||||
...> revoked?(Example.TokenRevocation, jti)
|
||||
false
|
||||
...> revoke(Example.TokenRevocation, token)
|
||||
...> revoked?(Example.TokenRevocation, jti)
|
||||
true
|
||||
"""
|
||||
@spec revoked?(Resource.t(), jti :: String.t()) :: boolean
|
||||
def revoked?(resource, jti) do
|
||||
with {:ok, api} <- Info.api(resource) do
|
||||
with {:ok, api} <- Info.revocation_api(resource) do
|
||||
resource
|
||||
|> Query.for_read(:revoked, %{jti: jti})
|
||||
|> api.read()
|
||||
|
@ -117,7 +134,7 @@ defmodule AshAuthentication.TokenRevocation do
|
|||
|
||||
## Note
|
||||
|
||||
Sadly this function iterates over all expired revocations and delete them
|
||||
Sadly this function iterates over all expired revocations and deletes them
|
||||
individually because Ash (as of v2.1.0) does not yet support bulk actions and
|
||||
we can't just drop down to Ecto because we can't assume that the user's
|
||||
resource uses an Ecto-backed data layer.
|
||||
|
@ -130,7 +147,7 @@ defmodule AshAuthentication.TokenRevocation do
|
|||
DataLayer.transaction(
|
||||
resource,
|
||||
fn ->
|
||||
with {:ok, api} <- Info.api(resource),
|
||||
with {:ok, api} <- Info.revocation_api(resource),
|
||||
query <- Query.for_read(resource, :expired),
|
||||
{:ok, expired} <- api.read(query) do
|
||||
expired
|
||||
|
@ -159,7 +176,7 @@ defmodule AshAuthentication.TokenRevocation do
|
|||
"""
|
||||
@spec remove_revocation(Resource.record()) :: :ok | {:error, any}
|
||||
def remove_revocation(revocation) do
|
||||
with {:ok, api} <- Info.api(revocation.__struct__) do
|
||||
with {:ok, api} <- Info.revocation_api(revocation.__struct__) do
|
||||
revocation
|
||||
|> Changeset.for_destroy(:expire)
|
||||
|> api.destroy()
|
||||
|
|
|
@ -2,7 +2,7 @@ defmodule AshAuthentication.TokenRevocation.Expunger do
|
|||
@default_period_hrs 12
|
||||
|
||||
@moduledoc """
|
||||
A genserver which periodically removes expired token revocations.
|
||||
A `GenServer` which periodically removes expired token revocations.
|
||||
|
||||
Scans all token revocation resources every #{@default_period_hrs} hours and removes
|
||||
any expired token revocations.
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
defmodule AshAuthentication.TokenRevocation.Info do
|
||||
@moduledoc """
|
||||
Generated configuration functions based on a resource's token DSL
|
||||
configuration.
|
||||
Introspection functions for the `AshAuthentication.TokenRevocation` Ash
|
||||
extension.
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
|
|
|
@ -29,8 +29,8 @@ defmodule AshAuthentication.Transformer do
|
|||
@spec transform(map) ::
|
||||
:ok | {:ok, map} | {:error, term} | {:warn, map, String.t() | [String.t()]} | :halt
|
||||
def transform(dsl_state) do
|
||||
with {:ok, api} <- validate_api_presence(dsl_state),
|
||||
:ok <- validate_at_least_one_authentication_provider(dsl_state),
|
||||
with {:ok, _api} <- validate_api_presence(dsl_state),
|
||||
:ok <- validate_at_least_one_strategy(dsl_state),
|
||||
{:ok, get_by_subject_action_name} <-
|
||||
Info.authentication_get_by_subject_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
|
@ -42,15 +42,8 @@ defmodule AshAuthentication.Transformer do
|
|||
:ok <- validate_read_action(dsl_state, get_by_subject_action_name),
|
||||
:ok <- validate_token_revocation_resource(dsl_state),
|
||||
subject_name <- find_or_generate_subject_name(dsl_state) do
|
||||
authentication =
|
||||
dsl_state
|
||||
|> Transformer.get_persisted(:authentication, %{providers: []})
|
||||
|> Map.put(:subject_name, subject_name)
|
||||
|> Map.put(:api, api)
|
||||
|
||||
dsl_state =
|
||||
dsl_state
|
||||
|> Transformer.persist(:authentication, authentication)
|
||||
|> Transformer.set_option([:authentication], :subject_name, subject_name)
|
||||
|
||||
{:ok, dsl_state}
|
||||
|
@ -109,7 +102,11 @@ defmodule AshAuthentication.Transformer do
|
|||
end
|
||||
|
||||
defp validate_api_presence(dsl_state) do
|
||||
case Transformer.get_option(dsl_state, [:authentication], :api) do
|
||||
with api when not is_nil(api) <- Transformer.get_option(dsl_state, [:authentication], :api),
|
||||
true <- function_exported?(api, :spark_is, 0),
|
||||
Ash.Api <- api.spark_is() do
|
||||
{:ok, api}
|
||||
else
|
||||
nil ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
|
@ -117,25 +114,28 @@ defmodule AshAuthentication.Transformer do
|
|||
message: "An API module must be present"
|
||||
)}
|
||||
|
||||
api ->
|
||||
{:ok, api}
|
||||
_ ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:authentication, :api],
|
||||
message: "Module is not an Ash.Api."
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_at_least_one_authentication_provider(dsl_state) do
|
||||
defp validate_at_least_one_strategy(dsl_state) do
|
||||
ok? =
|
||||
dsl_state
|
||||
|> Transformer.get_persisted(:extensions, [])
|
||||
|> Enum.any?(&Spark.implements_behaviour?(&1, AshAuthentication.Provider))
|
||||
|> Transformer.get_entities([:authentication, :strategies])
|
||||
|> Enum.any?()
|
||||
|
||||
if ok?,
|
||||
do: :ok,
|
||||
else:
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:extensions],
|
||||
message:
|
||||
"At least one authentication provider extension must also be present. See the documentation for more information."
|
||||
path: [:authentication, :strategies],
|
||||
message: "Expected at least one authentication strategy"
|
||||
)}
|
||||
end
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
defmodule AshAuthentication.ProviderIdentity do
|
||||
defmodule AshAuthentication.UserIdentity do
|
||||
@dsl [
|
||||
%Spark.Dsl.Section{
|
||||
name: :provider_identity,
|
||||
name: :user_identity,
|
||||
describe: "Configure identity options for this resource",
|
||||
schema: [
|
||||
api: [
|
||||
|
@ -19,10 +19,10 @@ defmodule AshAuthentication.ProviderIdentity do
|
|||
doc: "The name of the `uid` attribute on this resource.",
|
||||
default: :uid
|
||||
],
|
||||
provider_attribute_name: [
|
||||
strategy_attribute_name: [
|
||||
type: :atom,
|
||||
doc: "The name of the `provider` attribute on this resource.",
|
||||
default: :provider
|
||||
doc: "The name of the `strategy` attribute on this resource.",
|
||||
default: :strategy
|
||||
],
|
||||
user_id_attribute_name: [
|
||||
type: :atom,
|
||||
|
@ -69,21 +69,21 @@ defmodule AshAuthentication.ProviderIdentity do
|
|||
]
|
||||
|
||||
@moduledoc """
|
||||
An Ash extension which generates the default provider identities resource.
|
||||
An Ash extension which generates the default user identities resource.
|
||||
|
||||
The provider identities resource is used to store information returned by
|
||||
remote authentication providers (such as those provided by OAuth2) and maps
|
||||
them to your user resource(s). This provides the following benefits:
|
||||
The user identities resource is used to store information returned by remote
|
||||
authentication strategies (such as those provided by OAuth2) and maps them to
|
||||
your user resource(s). This provides the following benefits:
|
||||
|
||||
1. A user can be signed in to multiple authentication providers at once.
|
||||
1. A user can be signed in to multiple authentication strategies at once.
|
||||
2. For those provides which support it AshAuthentication can handle
|
||||
automatic refreshing of tokens.
|
||||
|
||||
## Storage
|
||||
|
||||
Provider identities are expected to be relatively long-lived (although they're
|
||||
User identities are expected to be relatively long-lived (although they're
|
||||
deleted on log out), so should probably be stored using a permanent data layer
|
||||
sush as `AshPostgres`.
|
||||
sush as `ash_postgres`.
|
||||
|
||||
## Usage
|
||||
|
||||
|
@ -95,9 +95,9 @@ defmodule AshAuthentication.ProviderIdentity do
|
|||
defmodule MyApp.Accounts.UserIdentity do
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication.ProviderIdentity]
|
||||
extensions: [AshAuthentication.UserIdentity]
|
||||
|
||||
provider_identity do
|
||||
user_identity do
|
||||
api MyApp.Accounts
|
||||
user_resource MyApp.Accounts.User
|
||||
end
|
||||
|
@ -110,7 +110,7 @@ defmodule AshAuthentication.ProviderIdentity do
|
|||
```
|
||||
|
||||
If you intend to operate with multiple user resources, you will need to define
|
||||
multiple provider identity resources.
|
||||
multiple user identity resources.
|
||||
|
||||
## Dsl
|
||||
|
||||
|
@ -125,5 +125,5 @@ defmodule AshAuthentication.ProviderIdentity do
|
|||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: @dsl,
|
||||
transformers: [AshAuthentication.ProviderIdentity.Transformer]
|
||||
transformers: [AshAuthentication.UserIdentity.Transformer]
|
||||
end
|
|
@ -1,22 +1,23 @@
|
|||
defmodule AshAuthentication.ProviderIdentity.Actions do
|
||||
defmodule AshAuthentication.UserIdentity.Actions do
|
||||
@moduledoc """
|
||||
Code interface for provider identity actions.
|
||||
|
||||
Allows you to interact with ProviderIdentity resources without having to mess
|
||||
Allows you to interact with UserIdentity resources without having to mess
|
||||
around with changesets, apis, etc. These functions are delegated to from
|
||||
within `AshAuthentication.ProviderIdentity`.
|
||||
within `AshAuthentication.UserIdentity`.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Resource}
|
||||
alias AshAuthentication.ProviderIdentity
|
||||
alias AshAuthentication.UserIdentity
|
||||
|
||||
@doc """
|
||||
Upsert an identity for a user.
|
||||
"""
|
||||
@spec upsert(Resource.t(), map) :: {:ok, Resource.record()} | {:error, term}
|
||||
def upsert(resource, attributes) do
|
||||
with {:ok, api} <- ProviderIdentity.Info.api(resource),
|
||||
{:ok, upsert_action_name} <- ProviderIdentity.Info.upsert_action_name(resource),
|
||||
with {:ok, api} <- UserIdentity.Info.user_identity_api(resource),
|
||||
{:ok, upsert_action_name} <-
|
||||
UserIdentity.Info.user_identity_upsert_action_name(resource),
|
||||
action when is_map(action) <- Resource.Info.action(resource, upsert_action_name) do
|
||||
resource
|
||||
|> Changeset.for_create(upsert_action_name, attributes,
|
10
lib/ash_authentication/user_identity/info.ex
Normal file
10
lib/ash_authentication/user_identity/info.ex
Normal file
|
@ -0,0 +1,10 @@
|
|||
defmodule AshAuthentication.UserIdentity.Info do
|
||||
@moduledoc """
|
||||
Introspection functions for the `AshAuthentication.UserIdentity` Ash
|
||||
extension.
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication.UserIdentity,
|
||||
sections: [:user_identity]
|
||||
end
|
|
@ -1,13 +1,13 @@
|
|||
defmodule AshAuthentication.ProviderIdentity.Transformer do
|
||||
defmodule AshAuthentication.UserIdentity.Transformer do
|
||||
@moduledoc """
|
||||
The provider identity transformer.
|
||||
The user identity transformer.
|
||||
|
||||
Sets up the default schema and actions for a provider identity resource.
|
||||
Sets up the default schema and actions for a user identity resource.
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
alias Ash.{Resource, Type}
|
||||
alias AshAuthentication.ProviderIdentity
|
||||
alias AshAuthentication.UserIdentity
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
|
@ -44,25 +44,29 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
default: &Ash.UUID.generate/0
|
||||
),
|
||||
:ok <- validate_id_field(dsl_state, :id),
|
||||
{:ok, uid} <- ProviderIdentity.Info.uid_attribute_name(dsl_state),
|
||||
{:ok, provider} <- ProviderIdentity.Info.provider_attribute_name(dsl_state),
|
||||
{:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state),
|
||||
{:ok, access_token} <- ProviderIdentity.Info.access_token_attribute_name(dsl_state),
|
||||
{:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
|
||||
{:ok, strategy} <-
|
||||
UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
|
||||
{:ok, user_id} <-
|
||||
UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state),
|
||||
{:ok, access_token} <-
|
||||
UserIdentity.Info.user_identity_access_token_attribute_name(dsl_state),
|
||||
{:ok, access_token_expires_at} <-
|
||||
ProviderIdentity.Info.access_token_expires_at_attribute_name(dsl_state),
|
||||
{:ok, refresh_token} <- ProviderIdentity.Info.refresh_token_attribute_name(dsl_state),
|
||||
UserIdentity.Info.user_identity_access_token_expires_at_attribute_name(dsl_state),
|
||||
{:ok, refresh_token} <-
|
||||
UserIdentity.Info.user_identity_refresh_token_attribute_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(dsl_state, provider, Type.String,
|
||||
maybe_build_attribute(dsl_state, strategy, Type.String,
|
||||
allow_nil?: false,
|
||||
writable?: true
|
||||
),
|
||||
:ok <- validate_provider_field(dsl_state, provider),
|
||||
:ok <- validate_strategy_field(dsl_state, strategy),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(dsl_state, uid, Type.String, allow_nil?: false, writable?: true),
|
||||
:ok <- validate_uid_field(dsl_state, uid),
|
||||
{:ok, dsl_state} <- maybe_build_identity(dsl_state, [user_id, uid, provider]),
|
||||
{:ok, dsl_state} <- maybe_build_identity(dsl_state, [user_id, uid, strategy]),
|
||||
:ok <-
|
||||
validate_attribute_unique_constraint(dsl_state, [user_id, uid, provider], resource),
|
||||
validate_attribute_unique_constraint(dsl_state, [user_id, uid, strategy], resource),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(dsl_state, access_token, Type.String,
|
||||
allow_nil?: true,
|
||||
|
@ -81,8 +85,9 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
writable?: true
|
||||
),
|
||||
:ok <- validate_token_field(dsl_state, refresh_token),
|
||||
{:ok, user_resource} <- ProviderIdentity.Info.user_resource(dsl_state),
|
||||
{:ok, user_relationship} <- ProviderIdentity.Info.user_relationship_name(dsl_state),
|
||||
{:ok, user_resource} <- UserIdentity.Info.user_identity_user_resource(dsl_state),
|
||||
{:ok, user_relationship} <-
|
||||
UserIdentity.Info.user_identity_user_relationship_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_relationship(
|
||||
dsl_state,
|
||||
|
@ -90,11 +95,13 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
&build_user_relationship(&1, user_relationship, user_resource)
|
||||
),
|
||||
:ok <- validate_user_relationship(dsl_state, user_relationship, user_resource),
|
||||
{:ok, upsert_action} <- ProviderIdentity.Info.upsert_action_name(dsl_state),
|
||||
{:ok, upsert_action} <-
|
||||
UserIdentity.Info.user_identity_upsert_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(dsl_state, upsert_action, &build_upsert_action(&1, upsert_action)),
|
||||
:ok <- validate_upsert_action(dsl_state, upsert_action),
|
||||
{:ok, destroy_action} <- ProviderIdentity.Info.destroy_action_name(dsl_state),
|
||||
{:ok, destroy_action} <-
|
||||
UserIdentity.Info.user_identity_destroy_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
|
@ -103,7 +110,8 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
),
|
||||
:ok <-
|
||||
validate_destroy_action(dsl_state, destroy_action),
|
||||
{:ok, read_action} <- ProviderIdentity.Info.read_action_name(dsl_state),
|
||||
{:ok, read_action} <-
|
||||
UserIdentity.Info.user_identity_read_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(dsl_state, read_action, &build_read_action(&1, read_action)),
|
||||
:ok <- validate_read_action(dsl_state, read_action) do
|
||||
|
@ -112,11 +120,11 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
end
|
||||
|
||||
defp validate_api_presence(dsl_state) do
|
||||
case Transformer.get_option(dsl_state, [:provider_identity], :api) do
|
||||
case Transformer.get_option(dsl_state, [:user_identity], :api) do
|
||||
nil ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:provider_identity, :api],
|
||||
path: [:user_identity, :api],
|
||||
message: "An API module must be present"
|
||||
)}
|
||||
|
||||
|
@ -135,7 +143,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
end
|
||||
end
|
||||
|
||||
defp validate_provider_field(dsl_state, field_name) do
|
||||
defp validate_strategy_field(dsl_state, field_name) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, field_name),
|
||||
:ok <- validate_attribute_option(attribute, resource, :type, [Type.String, :string]),
|
||||
|
@ -190,7 +198,8 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
defp build_user_relationship(dsl_state, name, destination) do
|
||||
with {:ok, id_attr} <- find_pk(destination),
|
||||
{:ok, api} <- AshAuthentication.Info.authentication_api(destination),
|
||||
{:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state) do
|
||||
{:ok, user_id} <-
|
||||
UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state) do
|
||||
Transformer.build_entity(Resource.Dsl, [:relationships], :belongs_to,
|
||||
name: name,
|
||||
destination: destination,
|
||||
|
@ -209,7 +218,8 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
with {:ok, id_attr} <- find_pk(destination),
|
||||
{:ok, api} <- AshAuthentication.Info.authentication_api(destination),
|
||||
{:ok, relationship} <- find_relationship(dsl_state, name),
|
||||
{:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state),
|
||||
{:ok, user_id} <-
|
||||
UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state),
|
||||
:ok <- validate_field_in_values(relationship, :destination, [destination]),
|
||||
:ok <- validate_field_in_values(relationship, :destination_attribute, [id_attr.name]),
|
||||
:ok <- validate_field_in_values(relationship, :source_attribute, [user_id]),
|
||||
|
@ -219,11 +229,13 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
end
|
||||
|
||||
defp build_upsert_action(dsl_state, action_name) do
|
||||
with {:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state),
|
||||
{:ok, uid} <- ProviderIdentity.Info.uid_attribute_name(dsl_state),
|
||||
{:ok, provider} <- ProviderIdentity.Info.provider_attribute_name(dsl_state),
|
||||
{:ok, identity} <- find_identity(dsl_state, [user_id, uid, provider]),
|
||||
{:ok, user_resource} <- ProviderIdentity.Info.user_resource(dsl_state),
|
||||
with {:ok, user_id} <-
|
||||
UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state),
|
||||
{:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
|
||||
{:ok, strategy} <-
|
||||
UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
|
||||
{:ok, identity} <- find_identity(dsl_state, [user_id, uid, strategy]),
|
||||
{:ok, user_resource} <- UserIdentity.Info.user_identity_user_resource(dsl_state),
|
||||
{:ok, user_resource_id} <- find_pk(user_resource) do
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :argument,
|
||||
|
@ -245,7 +257,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
|
||||
changes = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
|
||||
change: ProviderIdentity.UpsertIdentityChange
|
||||
change: UserIdentity.UpsertIdentityChange
|
||||
)
|
||||
]
|
||||
|
||||
|
@ -255,7 +267,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
upsert_identity: identity.name,
|
||||
arguments: arguments,
|
||||
changes: changes,
|
||||
accept: [provider]
|
||||
accept: [strategy]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
@ -266,22 +278,24 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
:ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]),
|
||||
:ok <- validate_action_argument_option(action, :oauth_tokens, :type, [:map, Type.Map]),
|
||||
:ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]),
|
||||
:ok <- validate_action_has_change(action, ProviderIdentity.UpsertIdentityChange),
|
||||
:ok <- validate_action_has_change(action, UserIdentity.UpsertIdentityChange),
|
||||
:ok <- validate_field_in_values(action, :type, [:create]),
|
||||
:ok <- validate_field_in_values(action, :upsert?, [true]),
|
||||
{:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state),
|
||||
{:ok, user_resource} <- ProviderIdentity.Info.user_resource(dsl_state),
|
||||
{:ok, user_id} <-
|
||||
UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state),
|
||||
{:ok, user_resource} <- UserIdentity.Info.user_identity_user_resource(dsl_state),
|
||||
{:ok, user_resource_id} <- find_pk(user_resource),
|
||||
:ok <- validate_action_argument_option(action, user_id, :type, [user_resource_id.type]),
|
||||
:ok <- validate_action_argument_option(action, user_id, :allow_nil?, [false]),
|
||||
{:ok, uid} <- ProviderIdentity.Info.uid_attribute_name(dsl_state),
|
||||
{:ok, provider} <- ProviderIdentity.Info.provider_attribute_name(dsl_state),
|
||||
{:ok, identity} <- find_identity(dsl_state, [uid, user_id, provider]),
|
||||
{:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
|
||||
{:ok, strategy} <-
|
||||
UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
|
||||
{:ok, identity} <- find_identity(dsl_state, [uid, user_id, strategy]),
|
||||
:ok <- validate_field_in_values(action, :upsert_identity, [identity.name]) do
|
||||
:ok
|
||||
else
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:error, DslError.exception(path: [:provider_identity], message: reason)}
|
||||
{:error, DslError.exception(path: [:user_identity], message: reason)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
@ -289,7 +303,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
|
|||
:error ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:provider_identity],
|
||||
path: [:user_identity],
|
||||
message: "Configuration error while validating upsert action."
|
||||
)}
|
||||
end
|
|
@ -1,6 +1,6 @@
|
|||
defmodule AshAuthentication.ProviderIdentity.UpsertIdentityChange do
|
||||
defmodule AshAuthentication.UserIdentity.UpsertIdentityChange do
|
||||
@moduledoc """
|
||||
A change which upserts a user's identity into the identity provider resource.
|
||||
A change which upserts a user's identity into the user identity resource.
|
||||
|
||||
Expects the following arguments:
|
||||
|
||||
|
@ -9,18 +9,21 @@ defmodule AshAuthentication.ProviderIdentity.UpsertIdentityChange do
|
|||
- `oauth_tokens` a map with string keys containing the OAuth2 token
|
||||
response.
|
||||
- `user_id` the ID of the user this identity relates to.
|
||||
- `provider` the name of the provider.
|
||||
- `strategy` the name of the strategy.
|
||||
|
||||
This is usually dynamically inserted into a generated action, however you can
|
||||
add it to your own action if needed.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias Ash.{Changeset, Resource.Change}
|
||||
alias AshAuthentication.ProviderIdentity.Info
|
||||
alias AshAuthentication.UserIdentity.Info
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
cfg = Info.options(changeset.resource)
|
||||
cfg = Info.user_identity_options(changeset.resource)
|
||||
|
||||
user_info = Changeset.get_argument(changeset, :user_info)
|
||||
oauth_tokens = Changeset.get_argument(changeset, :oauth_tokens)
|
|
@ -130,4 +130,22 @@ defmodule AshAuthentication.Utils do
|
|||
relationship -> {:ok, relationship}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Optionally set a field in a map.
|
||||
|
||||
Like `Map.put_new/3` except that it overwrites fields if their contents are
|
||||
falsy.
|
||||
"""
|
||||
@spec maybe_set_field(map, any, any) :: map
|
||||
def maybe_set_field(map, field, value) when is_falsy(:erlang.map_get(field, map)),
|
||||
do: Map.put(map, field, value)
|
||||
|
||||
def maybe_set_field(map, _field, _value), do: map
|
||||
|
||||
def maybe_set_field_lazy(map, field, generator)
|
||||
when is_falsy(:erlang.map_get(field, map)) and is_function(generator, 1),
|
||||
do: Map.put(map, field, generator.(map))
|
||||
|
||||
def maybe_set_field_lazy(map, _field, _generator), do: map
|
||||
end
|
||||
|
|
|
@ -113,7 +113,7 @@ defmodule AshAuthentication.Validations do
|
|||
"""
|
||||
@spec validate_token_generation_enabled(Dsl.t()) :: :ok | {:error, Exception.t()}
|
||||
def validate_token_generation_enabled(dsl_state) do
|
||||
if AshAuthentication.Info.tokens_enabled?(dsl_state),
|
||||
if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state),
|
||||
do: :ok,
|
||||
else:
|
||||
{:error,
|
||||
|
@ -156,4 +156,18 @@ defmodule AshAuthentication.Validations do
|
|||
"The `#{inspect(extension)}` extension must also be present on this resource for password authentication to work."
|
||||
)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Build an attribute if not present.
|
||||
"""
|
||||
@spec maybe_build_attribute(Dsl.t(), atom, (Dsl.t() -> {:ok, Attribute.t()})) :: {:ok, Dsl.t()}
|
||||
def maybe_build_attribute(dsl_state, attribute_name, builder) do
|
||||
with {:error, _} <- find_attribute(dsl_state, attribute_name),
|
||||
{:ok, attribute} <- builder.(dsl_state) do
|
||||
{:ok, Transformer.add_entity(dsl_state, [:attributes], attribute)}
|
||||
else
|
||||
{:ok, attribute} when is_struct(attribute, Attribute) -> {:ok, dsl_state}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
20
mix.exs
20
mix.exs
|
@ -30,8 +30,13 @@ defmodule AshAuthentication.MixProject do
|
|||
groups_for_modules: [
|
||||
Extensions: [
|
||||
AshAuthentication,
|
||||
AshAuthentication.PasswordAuthentication,
|
||||
AshAuthentication.TokenRevocation
|
||||
AshAuthentication.TokenRevocation,
|
||||
AshAuthentication.UserIdentity
|
||||
],
|
||||
Strategies: [
|
||||
AshAuthentication.Strategy,
|
||||
AshAuthentication.Strategy.Password,
|
||||
AshAuthentication.Strategy.OAuth2
|
||||
],
|
||||
Cryptography: [
|
||||
AshAuthentication.HashProvider,
|
||||
|
@ -39,9 +44,7 @@ defmodule AshAuthentication.MixProject do
|
|||
AshAuthentication.Jwt,
|
||||
AshAuthentication.Jwt.Config
|
||||
],
|
||||
"Password Authentication": ~r/^AshAuthentication\.PasswordAuthentication.*/,
|
||||
Plug: ~r/^AshAuthentication\.Plug.*/,
|
||||
"Token Revocation": ~r/^AshAuthentication\.TokenRevocation.*/,
|
||||
Internals: ~r/^AshAuthentication.*/
|
||||
]
|
||||
]
|
||||
|
@ -51,7 +54,8 @@ defmodule AshAuthentication.MixProject do
|
|||
def package do
|
||||
[
|
||||
maintainers: [
|
||||
"James Harton <james.harton@alembic.com.au>"
|
||||
"James Harton <james.harton@alembic.com.au>",
|
||||
"Zach Daniel <zach@zachdaniel.dev>"
|
||||
],
|
||||
licenses: ["MIT"],
|
||||
links: %{
|
||||
|
@ -78,6 +82,7 @@ defmodule AshAuthentication.MixProject do
|
|||
defp deps do
|
||||
[
|
||||
{:ash, "~> 2.4"},
|
||||
{:spark, "~> 0.2.12"},
|
||||
{:jason, "~> 1.4"},
|
||||
{:joken, "~> 2.5"},
|
||||
{:plug, "~> 1.13"},
|
||||
|
@ -86,9 +91,8 @@ defmodule AshAuthentication.MixProject do
|
|||
{:castore, "~> 0.1"},
|
||||
{:bcrypt_elixir, "~> 3.0"},
|
||||
{:absinthe_plug, "~> 1.5", only: [:dev, :test]},
|
||||
# These two can be changed back to hex once the next release goes out.
|
||||
{:ash_graphql, github: "ash-project/ash_graphql", only: [:dev, :test]},
|
||||
{:ash_json_api, github: "ash-project/ash_json_api", only: [:dev, :test]},
|
||||
{:ash_graphql, "~> 0.21", only: [:dev, :test]},
|
||||
{:ash_json_api, "~> 0.30", only: [:dev, :test]},
|
||||
{:ash_postgres, "~> 1.1", only: [:dev, :test]},
|
||||
{:credo, "~> 1.6", only: [:dev, :test], runtime: false},
|
||||
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
|
||||
|
|
14
mix.lock
14
mix.lock
|
@ -1,10 +1,10 @@
|
|||
%{
|
||||
"absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"},
|
||||
"absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"},
|
||||
"ash": {:hex, :ash, "2.4.10", "c3d17521515b05559ef1a592b421a6120d15679c07e84df4cd80b8df08088542", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, "~> 0.2 and >= 0.2.10", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e31630ccb9a42f092bc5ca0181d7cf34adef5a48df923ea42dc4256822bbbd89"},
|
||||
"ash_graphql": {:git, "https://github.com/ash-project/ash_graphql.git", "57e42cac6b7c58f96ee469c70be53b14d7135aa3", []},
|
||||
"ash_json_api": {:git, "https://github.com/ash-project/ash_json_api.git", "50b2785f31e9e8071b12942387e08b9f24a8602a", []},
|
||||
"ash_postgres": {:hex, :ash_postgres, "1.1.2", "1afd8ac43e68de8a92d22c8e8f3c36552665bc1c91dcdc4e5945d4e88c606bbe", [:mix], [{:ash, "~> 2.1", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "0877e0d5e7ff36b7c6b0f22ce95ed8980695a0ba309cba77c60cf911c9678854"},
|
||||
"ash": {:hex, :ash, "2.4.2", "ba579e6654c32b1da49f17938d2f1445066f27e61eedbf0fae431b816b49d1be", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:spark, ">= 0.2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "da8f94a19cf29617526ca2b1a75f6fae804c1db7c825b49982c603f503a615bd"},
|
||||
"ash_graphql": {:hex, :ash_graphql, "0.21.0", "b22b7786895552ef7bd4082815da5d895529dc9f81606a4a195f5790d163ebde", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 2.4.0", [hex: :ash, repo: "hexpm", optional: false]}, {:dataloader, "~> 1.0", [hex: :dataloader, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8b71047d86d7c279d4b6e185a33239a0a06486a0d574e9cc58628234e07aa5fc"},
|
||||
"ash_json_api": {:hex, :ash_json_api, "0.30.1", "54e60c4862eee35ed8a9a925e5c99be2b80e36a2507355bdb0f0974defe82a8d", [:mix], [{:ash, "~> 2.0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4.0", [hex: :json_xema, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b8b4827aa02de75a9a48d2941e813947da46b7dbfdd84cd20959dbaec103f830"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "1.1.1", "2bbc2b39d9e387f89b964b29b042f88dd352b71e486d9aea7f9390ab1db3ced4", [:mix], [{:ash, "~> 2.1", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "fe47a6e629b6b23ce17c1d70b1bd4b3fd732df513b67126514fb88be86a6439e"},
|
||||
"assent": {:hex, :assent, "0.2.1", "46ad0ed92b72330f38c60bc03c528e8408475dc386f48d4ecd18833cfa581b9f", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "58c558b6029ffa287e15b38c8e07cd99f0b24e4846c52abad0c0a6225c4873bc"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"},
|
||||
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
|
||||
|
@ -24,13 +24,13 @@
|
|||
"docsh": {:hex, :docsh, "0.7.2", "f893d5317a0e14269dd7fe79cf95fb6b9ba23513da0480ec6e77c73221cae4f2", [:rebar3], [{:providers, "1.8.1", [hex: :providers, repo: "hexpm", optional: false]}], "hexpm", "4e7db461bb07540d2bc3d366b8513f0197712d0495bb85744f367d3815076134"},
|
||||
"doctor": {:hex, :doctor, "0.20.0", "2a8ff8f87eaf3fc78f20ffcfa7a3181f2bdb6a115a4abd52582e6156a89649a5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "36ba43bdf7d799c41e1dc00b3429eb48bc5d4dc3f63b181ca1aa8829ec638862"},
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"},
|
||||
"ecto": {:hex, :ecto, "3.9.2", "017db3bc786ff64271108522c01a5d3f6ba0aea5c84912cfb0dd73bf13684108", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "21466d5177e09e55289ac7eade579a642578242c7a3a9f91ad5c6583337a9d15"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.9.1", "9bd5894eecc53d5b39d0c95180d4466aff00e10679e13a5cfa725f6f85c03c22", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fd470a4fff2e829bbf9dcceb7f3f9f6d1e49b4241e802f614de6b8b67c51118"},
|
||||
"ecto": {:hex, :ecto, "3.9.1", "67173b1687afeb68ce805ee7420b4261649d5e2deed8fe5550df23bab0bc4396", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c80bb3d736648df790f7f92f81b36c922d9dd3203ca65be4ff01d067f54eb304"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.9.0", "2bb21210a2a13317e098a420a8c1cc58b0c3421ab8e3acfa96417dab7817918c", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a8f3f720073b8b1ac4c978be25fa7960ed7fd44997420c304a4a2e200b596453"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"},
|
||||
"elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "ef2401be49e8471abb13ad1805067231973fecca", []},
|
||||
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
|
||||
"ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.29.0", "4a1cb903ce746aceef9c1f9ae8a6c12b742a5461e6959b9d3b24d813ffbea146", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "f096adb8bbca677d35d278223361c7792d496b3fc0d0224c9d4bc2f651af5db1"},
|
||||
"faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
|
||||
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||
"getopt": {:hex, :getopt, "1.0.1", "c73a9fa687b217f2ff79f68a3b637711bb1936e712b521d8ce466b29cbf7808a", [:rebar3], [], "hexpm", "53e1ab83b9ceb65c9672d3e7a35b8092e9bdc9b3ee80721471a161c10c59959c"},
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue