mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-20 05:13:10 +12:00
246 lines
6.9 KiB
Elixir
246 lines
6.9 KiB
Elixir
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.
|
|
"""
|
|
]
|
|
]
|
|
}
|
|
]
|
|
|
|
@moduledoc """
|
|
AshAuthentication provides a turn-key authentication solution for folks using
|
|
[Ash](https://www.ash-hq.org/).
|
|
|
|
## 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.
|
|
|
|
Once installed you can easily add support for authentication by configuring one
|
|
or more extensions onto your Ash resource:
|
|
|
|
```elixir
|
|
defmodule MyApp.Accounts.User do
|
|
use Ash.Resource,
|
|
extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication]
|
|
|
|
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
|
|
end
|
|
|
|
password_authentication do
|
|
identity_field :email
|
|
hashed_password_field :hashed_password
|
|
end
|
|
|
|
identities do
|
|
identity :unique_email, [:email]
|
|
end
|
|
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
|
|
routes incoming authentication requests to the correct provider and provides
|
|
callbacks for you to manipulate the conn after success or failure.
|
|
|
|
## Authentication Providers
|
|
|
|
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.
|
|
|
|
Planned future providers include:
|
|
|
|
* OAuth 1.0
|
|
* OAuth 2.0
|
|
* OpenID Connect
|
|
|
|
## DSL Documentation
|
|
|
|
### Index
|
|
|
|
#{Spark.Dsl.Extension.doc_index(@dsl)}
|
|
|
|
### Docs
|
|
|
|
#{Spark.Dsl.Extension.doc(@dsl)}
|
|
|
|
"""
|
|
alias Ash.{Api, Query, Resource}
|
|
alias AshAuthentication.Info
|
|
alias Spark.Dsl.Extension
|
|
|
|
use Spark.Dsl.Extension,
|
|
sections: @dsl,
|
|
transformers: [AshAuthentication.Transformer]
|
|
|
|
require Ash.Query
|
|
|
|
@type resource_config :: %{
|
|
api: module,
|
|
providers: [module],
|
|
resource: module,
|
|
subject_name: atom
|
|
}
|
|
|
|
@type subject :: String.t()
|
|
|
|
@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.
|
|
|
|
This is primarily useful for introspection, but also allows us to simplify
|
|
token lookup.
|
|
"""
|
|
@spec authenticated_resources(atom) :: [resource_config]
|
|
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))
|
|
|> 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.
|
|
"""
|
|
@spec resource_to_subject(Resource.record()) :: subject
|
|
def resource_to_subject(record) do
|
|
subject_name =
|
|
record.__struct__
|
|
|> AshAuthentication.Info.authentication_subject_name!()
|
|
|
|
record.__struct__
|
|
|> Resource.Info.primary_key()
|
|
|> then(&Map.take(record, &1))
|
|
|> then(fn primary_key ->
|
|
"#{subject_name}?#{URI.encode_query(primary_key)}"
|
|
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)
|
|
|
|
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
|
|
primary_key =
|
|
primary_key
|
|
|> URI.decode_query()
|
|
|> Enum.to_list()
|
|
|
|
config.resource
|
|
|> Query.for_read(action_name, %{})
|
|
|> Query.filter(^primary_key)
|
|
|> config.api.read()
|
|
|> case do
|
|
{:ok, [user]} -> {:ok, user}
|
|
_ -> {:error, "Invalid subject"}
|
|
end
|
|
end
|
|
end
|
|
end
|