mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 12:52:55 +12:00
feat(PasswordAuthentication): Registration and authentication with local credentials (#4)
This is missing a bunch of features that you probably want to use (eg confirmation, password resets), but it's a pretty good place to put a stake in the sand and say it works.
This commit is contained in:
parent
4a13721c9f
commit
a939dde9b9
70 changed files with 4381 additions and 33 deletions
|
@ -1,4 +1,5 @@
|
|||
version: "3.8"
|
||||
name: ash_authentication
|
||||
|
||||
volumes:
|
||||
apt-cache: {}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
%Doctor.Config{
|
||||
ignore_modules: [~r/^Inspect\./, ~r/.Plug$/],
|
||||
ignore_modules: [~r/^Inspect\./, ~r/.Plug$/, AshAuthentication.InfoGenerator],
|
||||
ignore_paths: [],
|
||||
min_module_doc_coverage: 40,
|
||||
min_module_spec_coverage: 0,
|
||||
min_overall_doc_coverage: 50,
|
||||
min_overall_spec_coverage: 0,
|
||||
moduledoc_required: true,
|
||||
min_overall_moduledoc_coverage: 100,
|
||||
exception_moduledoc_required: true,
|
||||
raise: false,
|
||||
reporter: Doctor.Reporters.Full,
|
||||
|
|
|
@ -1,4 +1,25 @@
|
|||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
spark_locals_without_parens = [
|
||||
api: 1,
|
||||
confirmation_required?: 1,
|
||||
hash_provider: 1,
|
||||
hashed_password_field: 1,
|
||||
identity_field: 1,
|
||||
password_confirmation_field: 1,
|
||||
password_field: 1,
|
||||
read_action_name: 1,
|
||||
register_action_name: 1,
|
||||
sign_in_action_name: 1,
|
||||
subject_name: 1
|
||||
]
|
||||
|
||||
[
|
||||
import_deps: [:ash, :spark],
|
||||
inputs: [
|
||||
"*.{ex,exs}",
|
||||
"{config,lib,test}/**/*.{ex,exs}"
|
||||
],
|
||||
plugins: [Spark.Formatter],
|
||||
export: [
|
||||
locals_without_parens: spark_locals_without_parens
|
||||
]
|
||||
]
|
||||
|
|
|
@ -7,3 +7,24 @@ config :git_ops,
|
|||
manage_mix_version?: true,
|
||||
manage_readme_version: "README.md",
|
||||
version_tag_prefix: "v"
|
||||
|
||||
config :ash_authentication, DevServer, start?: true, port: 4000
|
||||
|
||||
config :ash_authentication, ecto_repos: [Example.Repo], ash_apis: [Example]
|
||||
|
||||
config :ash_authentication, Example.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
hostname: "localhost",
|
||||
database: "ash_authentication_dev",
|
||||
stacktrace: true,
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
pool_size: 10
|
||||
|
||||
config :ash_authentication, Example,
|
||||
resources: [
|
||||
registry: Example.Registry
|
||||
]
|
||||
|
||||
config :ash_authentication, AshAuthentication.Jwt,
|
||||
signing_secret: "Marty McFly in the past with the Delorean"
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import Config
|
||||
|
||||
config :ash_authentication, ecto_repos: [Example.Repo], ash_apis: [Example]
|
||||
|
||||
config :ash_authentication, Example.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
hostname: "localhost",
|
||||
database: "ash_authentication_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
pool_size: 10
|
||||
|
||||
config :ash_authentication, Example,
|
||||
resources: [
|
||||
registry: Example.Registry
|
||||
]
|
||||
|
||||
config :bcrypt_elixir, :log_rounds, 4
|
||||
|
||||
config :ash, :disable_async?, true
|
||||
|
||||
config :ash_authentication, AshAuthentication.Jwt,
|
||||
signing_secret: "Marty McFly in the past with the Delorean"
|
24
dev/dev_server.ex
Normal file
24
dev/dev_server.ex
Normal file
|
@ -0,0 +1,24 @@
|
|||
defmodule DevServer do
|
||||
@moduledoc """
|
||||
This module provides an extremely simplified authentication UI, mainly for
|
||||
local development and testing.
|
||||
"""
|
||||
|
||||
use Supervisor
|
||||
|
||||
def start_link(init_arg), do: Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
|
||||
|
||||
@impl true
|
||||
def init(_init_arg) do
|
||||
opts =
|
||||
:ash_authentication
|
||||
|> Application.get_env(DevServer, [])
|
||||
|> Keyword.delete(:start?)
|
||||
|
||||
[
|
||||
{DevServer.Session, []},
|
||||
{Plug.Cowboy, scheme: :http, plug: DevServer.Plug, options: opts}
|
||||
]
|
||||
|> Supervisor.init(strategy: :one_for_all)
|
||||
end
|
||||
end
|
23
dev/dev_server/clear_session.ex
Normal file
23
dev/dev_server/clear_session.ex
Normal file
|
@ -0,0 +1,23 @@
|
|||
defmodule DevServer.ClearSession do
|
||||
@moduledoc """
|
||||
Resets the session storage, to 'log out" all actors.
|
||||
"""
|
||||
|
||||
@behaviour Plug
|
||||
alias Plug.Conn
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init(keyword) :: keyword
|
||||
def init(opts), do: opts
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec call(Conn.t(), any) :: Conn.t()
|
||||
def call(conn, _opts) do
|
||||
conn
|
||||
|> Conn.clear_session()
|
||||
|> Conn.put_resp_header("location", "/")
|
||||
|> Conn.send_resp(302, "Redirected")
|
||||
end
|
||||
end
|
24
dev/dev_server/plug.ex
Normal file
24
dev/dev_server/plug.ex
Normal file
|
@ -0,0 +1,24 @@
|
|||
defmodule DevServer.Plug do
|
||||
@moduledoc false
|
||||
use Plug.Router
|
||||
alias DevServer
|
||||
import Example.AuthPlug
|
||||
|
||||
plug(Plug.Parsers, parsers: [:urlencoded, :multipart, :json], json_decoder: Jason)
|
||||
plug(Plug.Session, store: :ets, key: "_ash_authentication_session", table: DevServer.Session)
|
||||
plug(:fetch_query_params)
|
||||
plug(:fetch_session)
|
||||
plug(Plug.Logger)
|
||||
plug(:load_from_session)
|
||||
plug(:match)
|
||||
plug(:dispatch)
|
||||
|
||||
forward("/auth", to: Example.AuthPlug.Router)
|
||||
get("/clear_session", to: DevServer.ClearSession)
|
||||
post("/token_check", to: DevServer.TokenCheck)
|
||||
get("/", to: DevServer.TestPage)
|
||||
|
||||
match _ do
|
||||
send_resp(conn, 404, "NOT FOUND")
|
||||
end
|
||||
end
|
17
dev/dev_server/session.ex
Normal file
17
dev/dev_server/session.ex
Normal file
|
@ -0,0 +1,17 @@
|
|||
defmodule DevServer.Session do
|
||||
@moduledoc """
|
||||
Does nothing but own an ETS table for the session to be stored in.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
@doc false
|
||||
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, [])
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def init(_) do
|
||||
table_ref = :ets.new(__MODULE__, [:named_table, :public, read_concurrency: true])
|
||||
{:ok, table_ref, :hibernate}
|
||||
end
|
||||
end
|
37
dev/dev_server/test_page.ex
Normal file
37
dev/dev_server/test_page.ex
Normal file
|
@ -0,0 +1,37 @@
|
|||
defmodule DevServer.TestPage do
|
||||
@moduledoc """
|
||||
Displays a very basic login form according to the currently configured
|
||||
Überauth providers.
|
||||
"""
|
||||
@behaviour Plug
|
||||
alias Plug.Conn
|
||||
require EEx
|
||||
|
||||
EEx.function_from_file(:defp, :render, String.replace(__ENV__.file, ".ex", ".html.eex"), [
|
||||
:assigns
|
||||
])
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init(keyword) :: keyword
|
||||
def init(opts), do: opts
|
||||
|
||||
@doc false
|
||||
@spec call(Conn.t(), any) :: Conn.t()
|
||||
@impl true
|
||||
def call(conn, _opts) do
|
||||
resources = AshAuthentication.authenticated_resources(:ash_authentication)
|
||||
|
||||
current_actors =
|
||||
conn.assigns
|
||||
|> Stream.filter(fn {key, _value} ->
|
||||
key
|
||||
|> to_string()
|
||||
|> String.starts_with?("current_")
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
payload = render(resources: resources, current_actors: current_actors)
|
||||
Conn.send_resp(conn, 200, payload)
|
||||
end
|
||||
end
|
53
dev/dev_server/test_page.html.eex
Normal file
53
dev/dev_server/test_page.html.eex
Normal file
|
@ -0,0 +1,53 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Ash Authentication</title>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Ash Authentication</h1>
|
||||
<%= 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 provider <- config.providers do %>
|
||||
<%= Module.concat(provider, HTML).register(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides()}/callback", legend: "Register") %>
|
||||
<%= Module.concat(provider, HTML).sign_in(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides()}/callback", legend: "Sign in") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<h2>Validate token</h2>
|
||||
<form method="post" action="/token_check">
|
||||
<textarea name="token"></textarea>
|
||||
<br />
|
||||
<input type="submit" value="Check token" />
|
||||
</form>
|
||||
<% else %>
|
||||
<p>
|
||||
<strong>No resources configured</strong>
|
||||
<br />
|
||||
Please see <a href="https://hexdocs.pm/ash_authentication">the documentation</a> for more information.
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.any?(@current_actors) do %>
|
||||
<h2>Current actors:</h2>
|
||||
<a href="/clear_session">Clear session</a>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
<%= for {name, actor} <- @current_actors do %>
|
||||
<tr>
|
||||
<td><code><pre>@<%= name %></pre></code></td>
|
||||
<td><code><pre><%= inspect actor, pretty: true %></pre></code></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</table>
|
||||
<% end %>
|
||||
</body>
|
||||
</html>
|
||||
|
26
dev/dev_server/token_check.ex
Normal file
26
dev/dev_server/token_check.ex
Normal file
|
@ -0,0 +1,26 @@
|
|||
defmodule DevServer.TokenCheck do
|
||||
@moduledoc """
|
||||
Verifies a submitted token and reports the contents.
|
||||
"""
|
||||
|
||||
@behaviour Plug
|
||||
alias AshAuthentication.Jwt
|
||||
alias Plug.Conn
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init(keyword) :: keyword
|
||||
def init(opts), do: opts
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec call(Conn.t(), any) :: Conn.t()
|
||||
def call(%{params: %{"token" => token}} = conn, _opts) do
|
||||
result = Jwt.verify(token, :ash_authentication)
|
||||
|
||||
conn
|
||||
|> Conn.send_resp(200, inspect(result))
|
||||
end
|
||||
|
||||
def call(conn, _opts), do: Conn.send_resp(conn, 200, "Invalid request")
|
||||
end
|
|
@ -1,18 +1,176 @@
|
|||
defmodule AshAuthentication do
|
||||
import AshAuthentication.Utils, only: [to_sentence: 2]
|
||||
|
||||
@authentication %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.
|
||||
"""
|
||||
],
|
||||
api: [
|
||||
type: {:behaviour, Ash.Api},
|
||||
doc: """
|
||||
The name of the Ash API to use to access this resource when registering/authenticating.
|
||||
"""
|
||||
],
|
||||
get_by_subject_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the read action used to retrieve the access when calling `AshAuthentication.subject_to_resource/2`.
|
||||
""",
|
||||
default: :get_by_subject
|
||||
]
|
||||
]
|
||||
}
|
||||
@tokens %Spark.Dsl.Section{
|
||||
name: :tokens,
|
||||
describe: "Configure JWT settings for this resource",
|
||||
schema: [
|
||||
enabled?: [
|
||||
type: :boolean,
|
||||
doc: """
|
||||
Should JWTs be generated by this resource?
|
||||
"""
|
||||
],
|
||||
signing_algorithm: [
|
||||
type: :string,
|
||||
doc: """
|
||||
The algorithm to use for token signing.
|
||||
|
||||
Available signing algorithms are; #{to_sentence(Joken.Signer.algorithms(), final: "and")}.
|
||||
"""
|
||||
],
|
||||
token_lifetime: [
|
||||
type: :pos_integer,
|
||||
doc: """
|
||||
How long a token should be valid, in hours.
|
||||
"""
|
||||
],
|
||||
revocation_resource: [
|
||||
type: {:behaviour, Ash.Resource},
|
||||
doc: """
|
||||
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 """
|
||||
Documentation for `AshAuthentication`.
|
||||
AshAuthentication
|
||||
|
||||
AshAuthentication provides a turn-key authentication solution for folks using
|
||||
[Ash](https://www.ash-hq.org/).
|
||||
|
||||
|
||||
## Authentication DSL
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index([@authentication])}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc([@authentication])}
|
||||
|
||||
## Token DSL
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index([@tokens])}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc([@tokens])}
|
||||
|
||||
"""
|
||||
alias Ash.{Api, Query, Resource}
|
||||
alias AshAuthentication.Info
|
||||
alias Spark.Dsl.Extension
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: [@authentication, @tokens],
|
||||
transformers: [AshAuthentication.Transformer]
|
||||
|
||||
require Ash.Query
|
||||
|
||||
@type resource_config :: %{
|
||||
api: module,
|
||||
providers: [module],
|
||||
resource: module,
|
||||
subject_name: atom
|
||||
}
|
||||
|
||||
@type subject :: String.t()
|
||||
|
||||
@doc """
|
||||
Hello world.
|
||||
Find all resources which support authentication for a given OTP application.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> AshAuthentication.hello()
|
||||
:world
|
||||
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.
|
||||
"""
|
||||
def hello do
|
||||
:world
|
||||
@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(&{&1, Extension.get_persisted(&1, :authentication)})
|
||||
|> Stream.reject(&(elem(&1, 1) == nil))
|
||||
|> Stream.map(fn {resource, config} ->
|
||||
Map.put(config, :resource, resource)
|
||||
end)
|
||||
|> Enum.to_list()
|
||||
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, [actor]} -> {:ok, actor}
|
||||
_ -> {:error, "Invalid subject"}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,20 +1,26 @@
|
|||
defmodule AshAuthentication.Application do
|
||||
# See https://hexdocs.pm/elixir/Application.html
|
||||
# for more information on OTP Applications
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
import AshAuthentication.Utils, only: [maybe_append: 3]
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
# Starts a worker by calling: AshAuthentication.Worker.start_link(arg)
|
||||
# {AshAuthentication.Worker, arg}
|
||||
]
|
||||
[AshAuthentication.TokenRevocation.Expunger]
|
||||
|> maybe_append(start_dev_server?(), {DevServer, []})
|
||||
|> maybe_append(start_repo?(), {Example.Repo, []})
|
||||
|> Supervisor.start_link(strategy: :one_for_one, name: AshAuthentication.Supervisor)
|
||||
end
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: AshAuthentication.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
defp start_dev_server? do
|
||||
:ash_authentication
|
||||
|> Application.get_env(DevServer, [])
|
||||
|> Keyword.get(:start?, false)
|
||||
end
|
||||
|
||||
defp start_repo? do
|
||||
repos = Application.get_env(:ash_authentication, :ecto_repos, [])
|
||||
Example.Repo in repos
|
||||
end
|
||||
end
|
||||
|
|
29
lib/ash_authentication/bcrypt_provider.ex
Normal file
29
lib/ash_authentication/bcrypt_provider.ex
Normal file
|
@ -0,0 +1,29 @@
|
|||
defmodule AshAuthentication.BcryptProvider do
|
||||
@moduledoc """
|
||||
Provides the default implementation of `AshAuthentication.HashProvider` using `Bcrypt`.
|
||||
"""
|
||||
@behaviour AshAuthentication.HashProvider
|
||||
|
||||
@doc """
|
||||
Given some user input as a string, convert it into it's hashed form using `Bcrypt`.
|
||||
"""
|
||||
@impl true
|
||||
@spec hash(String.t()) :: {:ok, String.t()} | :error
|
||||
def hash(input) when is_binary(input), do: {:ok, Bcrypt.hash_pwd_salt(input)}
|
||||
def hash(_), do: :error
|
||||
|
||||
@doc """
|
||||
Check if the user input matches the hash.
|
||||
"""
|
||||
@impl true
|
||||
@spec valid?(input :: String.t(), hash :: String.t()) :: boolean
|
||||
def valid?(input, hash) when is_binary(input) and is_binary(hash),
|
||||
do: Bcrypt.verify_pass(input, hash)
|
||||
|
||||
@doc """
|
||||
Simulate a password check to help avoid timing attacks.
|
||||
"""
|
||||
@impl true
|
||||
@spec simulate :: false
|
||||
def simulate, do: Bcrypt.no_user_verify()
|
||||
end
|
14
lib/ash_authentication/errors/authentication_failed.ex
Normal file
14
lib/ash_authentication/errors/authentication_failed.ex
Normal file
|
@ -0,0 +1,14 @@
|
|||
defmodule AshAuthentication.Errors.AuthenticationFailed do
|
||||
@moduledoc """
|
||||
A generic, authentication failed error.
|
||||
"""
|
||||
use Ash.Error.Exception
|
||||
def_ash_error([], class: :forbidden)
|
||||
|
||||
defimpl Ash.ErrorKind do
|
||||
@moduledoc false
|
||||
def id(_), do: Ecto.UUID.generate()
|
||||
def code(_), do: "authentication_failed"
|
||||
def message(_), do: "Authentication failed"
|
||||
end
|
||||
end
|
22
lib/ash_authentication/hash_provider.ex
Normal file
22
lib/ash_authentication/hash_provider.ex
Normal file
|
@ -0,0 +1,22 @@
|
|||
defmodule AshAuthentication.HashProvider do
|
||||
@moduledoc """
|
||||
A behaviour providing password hashing.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Given some user input as a string, convert it into it's hashed form.
|
||||
"""
|
||||
@callback hash(input :: String.t()) :: {:ok, hash :: String.t()} | :error
|
||||
|
||||
@doc """
|
||||
Check if the user input matches the hash.
|
||||
"""
|
||||
@callback valid?(input :: String.t(), hash :: String.t()) :: boolean()
|
||||
|
||||
@doc """
|
||||
Attempt to defeat timing attacks by simulating a password hash check.
|
||||
|
||||
See [Bcrypt.no_user_verify/1](https://hexdocs.pm/bcrypt_elixir/Bcrypt.html#no_user_verify/1) for more information.
|
||||
"""
|
||||
@callback simulate :: false
|
||||
end
|
10
lib/ash_authentication/info.ex
Normal file
10
lib/ash_authentication/info.ex
Normal file
|
@ -0,0 +1,10 @@
|
|||
defmodule AshAuthentication.Info do
|
||||
@moduledoc """
|
||||
Generated configuration functions based on a resource's DSL configuration.
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication,
|
||||
sections: [:authentication, :tokens],
|
||||
prefix?: true
|
||||
end
|
170
lib/ash_authentication/info_generator.ex
Normal file
170
lib/ash_authentication/info_generator.ex
Normal file
|
@ -0,0 +1,170 @@
|
|||
defmodule AshAuthentication.InfoGenerator do
|
||||
@moduledoc """
|
||||
Used to dynamically generate configuration functions for Spark extensions
|
||||
based on their DSL.
|
||||
|
||||
## Usage
|
||||
|
||||
```elixir
|
||||
defmodule MyConfig do
|
||||
use AshAuthentication.InfoGenerator, extension: MyDslExtension, sections: [:my_section]
|
||||
end
|
||||
```
|
||||
"""
|
||||
|
||||
@type options :: [{:extension, module} | {:sections, [atom]} | {:prefix?, boolean}]
|
||||
|
||||
@doc false
|
||||
@spec __using__(options) :: Macro.t()
|
||||
defmacro __using__(opts) do
|
||||
extension = Keyword.fetch!(opts, :extension)
|
||||
sections = Keyword.get(opts, :sections, [])
|
||||
prefix? = Keyword.get(opts, :prefix?, false)
|
||||
|
||||
quote do
|
||||
require unquote(extension)
|
||||
end
|
||||
|
||||
for section <- sections do
|
||||
quote do
|
||||
AshAuthentication.InfoGenerator.generate_options_function(
|
||||
unquote(extension),
|
||||
unquote(section),
|
||||
unquote(prefix?)
|
||||
)
|
||||
|
||||
AshAuthentication.InfoGenerator.generate_config_functions(
|
||||
unquote(extension),
|
||||
unquote(section),
|
||||
unquote(prefix?)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec generate_config_functions(module, atom, boolean) :: Macro.t()
|
||||
defmacro generate_config_functions(extension, section, prefix?) do
|
||||
options =
|
||||
extension
|
||||
|> Macro.expand_literal(__ENV__)
|
||||
|> apply(:sections, [])
|
||||
|> Enum.find(&(&1.name == section))
|
||||
|> Map.get(:schema, [])
|
||||
|
||||
for {name, opts} <- options do
|
||||
pred? = name |> to_string() |> String.ends_with?("?")
|
||||
function_name = if prefix?, do: :"#{section}_#{name}", else: name
|
||||
|
||||
if pred? do
|
||||
generate_predicate_function(function_name, section, name, Keyword.get(opts, :doc, false))
|
||||
else
|
||||
spec = AshAuthentication.Utils.spec_for_option(opts)
|
||||
|
||||
quote generated: true do
|
||||
unquote(
|
||||
generate_config_function(
|
||||
function_name,
|
||||
section,
|
||||
name,
|
||||
Keyword.get(opts, :doc, false),
|
||||
spec
|
||||
)
|
||||
)
|
||||
|
||||
unquote(
|
||||
generate_config_bang_function(
|
||||
function_name,
|
||||
section,
|
||||
name,
|
||||
Keyword.get(opts, :doc, false),
|
||||
spec
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_predicate_function(function_name, section, name, doc) do
|
||||
quote generated: true do
|
||||
@doc unquote(doc)
|
||||
@spec unquote(function_name)(dsl_or_resource :: module | map) :: boolean
|
||||
def unquote(function_name)(dsl_or_resource) do
|
||||
import Spark.Dsl.Extension, only: [get_opt: 4]
|
||||
get_opt(dsl_or_resource, [unquote(section)], unquote(name), false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_config_function(function_name, section, name, doc, spec) do
|
||||
quote generated: true do
|
||||
@doc unquote(doc)
|
||||
@spec unquote(function_name)(dsl_or_resource :: module | map) ::
|
||||
{:ok, unquote(spec)} | :error
|
||||
|
||||
def unquote(function_name)(dsl_or_resource) do
|
||||
import Spark.Dsl.Extension, only: [get_opt: 4]
|
||||
|
||||
case get_opt(dsl_or_resource, [unquote(section)], unquote(name), :error) do
|
||||
:error -> :error
|
||||
value -> {:ok, value}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_config_bang_function(function_name, section, name, doc, spec) do
|
||||
quote generated: true do
|
||||
@doc unquote(doc)
|
||||
@spec unquote(:"#{function_name}!")(dsl_or_resource :: module | map) ::
|
||||
unquote(spec) | no_return
|
||||
|
||||
def unquote(:"#{function_name}!")(dsl_or_resource) do
|
||||
import Spark.Dsl.Extension, only: [get_opt: 4]
|
||||
|
||||
case get_opt(dsl_or_resource, [unquote(section)], unquote(name), :error) do
|
||||
:error ->
|
||||
raise "No configuration for `#{unquote(name)}` present on `#{inspect(dsl_or_resource)}`."
|
||||
|
||||
value ->
|
||||
value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec generate_options_function(module, atom, boolean) :: Macro.t()
|
||||
defmacro generate_options_function(extension, section, prefix?) do
|
||||
options =
|
||||
extension
|
||||
|> Macro.expand_literal(__ENV__)
|
||||
|> apply(:sections, [])
|
||||
|> Enum.find(&(&1.name == section))
|
||||
|> Map.get(:schema, [])
|
||||
|
||||
function_name = if prefix?, do: :"#{section}_options", else: :options
|
||||
|
||||
quote generated: true do
|
||||
@doc """
|
||||
The DSL options
|
||||
|
||||
Returns a map containing the schema and any configured or default values.
|
||||
"""
|
||||
@spec unquote(function_name)(dsl_or_resource :: module | map) :: %{required(atom) => any}
|
||||
def unquote(function_name)(dsl_or_resource) do
|
||||
import Spark.Dsl.Extension, only: [get_opt: 3]
|
||||
|
||||
Enum.reduce(unquote(options), %{}, fn {name, opts}, result ->
|
||||
with nil <- get_opt(dsl_or_resource, [unquote(section)], name),
|
||||
nil <- Keyword.get(opts, :default) do
|
||||
result
|
||||
else
|
||||
value -> Map.put(result, name, value)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
131
lib/ash_authentication/jwt.ex
Normal file
131
lib/ash_authentication/jwt.ex
Normal file
|
@ -0,0 +1,131 @@
|
|||
defmodule AshAuthentication.Jwt do
|
||||
@default_algorithm "HS256"
|
||||
@default_lifetime_hrs 7 * 24
|
||||
@supported_algorithms Joken.Signer.algorithms()
|
||||
import AshAuthentication.Utils, only: [to_sentence: 2]
|
||||
|
||||
@moduledoc """
|
||||
Uses the excellent `joken` hex package to generate and sign Json Web Tokens.
|
||||
|
||||
## Configuration
|
||||
|
||||
There are a few things we need to know in order to generate and sign a JWT:
|
||||
|
||||
* `signing_algorithm` - the crypographic algorithm used to to sign tokens.
|
||||
Instance-wide configuration is configured by the application environment,
|
||||
but can be overriden on a per-resource basis.
|
||||
* `token_lifetime` - how long the token is valid for (in hours).
|
||||
Instance-wide configuration is configured by the application environment,
|
||||
but can be overriden on a per-resource basis.
|
||||
* `signing_secret` - the secret key used to sign the tokens. Only
|
||||
configurable via the application environment.
|
||||
|
||||
```elixir
|
||||
config :ash_authentication, #{inspect(__MODULE__)},
|
||||
signing_algorithm: #{@default_algorithm}
|
||||
signing_secret: "I finally invent something that works!",
|
||||
token_lifetime: #{@default_lifetime_hrs} # #{@default_lifetime_hrs / 24.0} days
|
||||
```
|
||||
|
||||
Available signing algorithms are #{to_sentence(@supported_algorithms, final: "or")}. Defaults to #{@default_algorithm}.
|
||||
|
||||
We strongly advise against storing the signing secret in your mix config. We
|
||||
instead suggest you make use of
|
||||
[`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
|
||||
in integer positive hours.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.Jwt.Config
|
||||
|
||||
@typedoc """
|
||||
A string likely to contain a valid JWT.
|
||||
"""
|
||||
@type token :: String.t()
|
||||
|
||||
@typedoc """
|
||||
"claims" are the decoded contents of a JWT. A map of (short) string keys to
|
||||
string values.
|
||||
"""
|
||||
@type claims :: %{required(String.t()) => String.t()}
|
||||
|
||||
@doc "The default signing algorithm"
|
||||
@spec default_algorithm :: String.t()
|
||||
def default_algorithm, do: @default_algorithm
|
||||
|
||||
@doc "Supported signing algorithms"
|
||||
@spec supported_algorithms :: [String.t()]
|
||||
def supported_algorithms, do: @supported_algorithms
|
||||
|
||||
@doc "The default token lifetime"
|
||||
@spec default_lifetime_hrs :: pos_integer
|
||||
def default_lifetime_hrs, do: @default_lifetime_hrs
|
||||
|
||||
@doc """
|
||||
Given a record, generate a signed JWT for use while authenticating.
|
||||
"""
|
||||
@spec token_for_record(Resource.record()) :: {:ok, token, claims} | :error
|
||||
def token_for_record(record) do
|
||||
resource = record.__struct__
|
||||
|
||||
default_claims = Config.default_claims(resource)
|
||||
signer = Config.token_signer(resource)
|
||||
|
||||
subject = AshAuthentication.resource_to_subject(record)
|
||||
extra_claims = %{"sub" => subject}
|
||||
|
||||
extra_claims =
|
||||
case Map.fetch(record.__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)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Given a token, verify it's signature and validate it's claims.
|
||||
"""
|
||||
@spec verify(token, module) ::
|
||||
{:ok, claims, AshAuthentication.resource_config()} | :error
|
||||
def verify(token, otp_app) do
|
||||
with {:ok, config} <- token_to_resource(token, otp_app),
|
||||
signer <- Config.token_signer(config.resource),
|
||||
{:ok, claims} <- Joken.verify(token, signer),
|
||||
defaults <- Config.default_claims(config.resource),
|
||||
{:ok, claims} <- Joken.validate(defaults, claims, config) do
|
||||
{:ok, claims, config}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Given a token, find a matching resource configuration.
|
||||
|
||||
## Warning
|
||||
|
||||
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
|
||||
def token_to_resource(token, otp_app) do
|
||||
with {:ok, %{"sub" => subject}} <- Joken.peek_claims(token),
|
||||
%URI{path: subject_name} <- URI.parse(subject) do
|
||||
config_for_subject_name(subject_name, otp_app)
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp config_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}
|
||||
end)
|
||||
end
|
||||
end
|
144
lib/ash_authentication/jwt/config.ex
Normal file
144
lib/ash_authentication/jwt/config.ex
Normal file
|
@ -0,0 +1,144 @@
|
|||
defmodule AshAuthentication.Jwt.Config do
|
||||
@moduledoc """
|
||||
Implementation details JWT generation and validation.
|
||||
|
||||
Provides functions to generate token configuration at runtime, based on the
|
||||
resource being signed for and for verifying claims and checking for token
|
||||
revocation.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.{Info, Jwt, TokenRevocation}
|
||||
alias Joken.{Config, Signer}
|
||||
|
||||
@doc """
|
||||
Generate the default claims for a specified resource.
|
||||
"""
|
||||
@spec default_claims(Resource.t()) :: Joken.token_config()
|
||||
def default_claims(resource) do
|
||||
config =
|
||||
resource
|
||||
|> config()
|
||||
|
||||
{:ok, vsn} = :application.get_key(:ash_authentication, :vsn)
|
||||
|
||||
vsn =
|
||||
vsn
|
||||
|> to_string()
|
||||
|> Version.parse!()
|
||||
|
||||
Config.default_claims(default_exp: token_lifetime(config))
|
||||
|> Config.add_claim(
|
||||
"iss",
|
||||
fn -> generate_issuer(vsn) end,
|
||||
&validate_issuer/3
|
||||
)
|
||||
|> Config.add_claim(
|
||||
"aud",
|
||||
fn -> generate_audience(vsn) end,
|
||||
&validate_audience(&1, &2, &3, vsn)
|
||||
)
|
||||
|> Config.add_claim(
|
||||
"jti",
|
||||
&Joken.generate_jti/0,
|
||||
&validate_jti/3
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
The generator function used to generate the "iss" claim.
|
||||
"""
|
||||
@spec generate_issuer(Version.t()) :: String.t()
|
||||
def generate_issuer(vsn) do
|
||||
"AshAuthentication v#{vsn}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
The validation function used to validate the "iss" claim.
|
||||
|
||||
It simply verifies that the claim starts with `"AshAuthentication"`
|
||||
"""
|
||||
@spec validate_issuer(String.t(), any, any) :: boolean
|
||||
def validate_issuer(claim, _, _), do: String.starts_with?(claim, "AshAuthentication")
|
||||
|
||||
@doc """
|
||||
The generator function used to generate the "aud" claim.
|
||||
|
||||
It generates an Elixir-style `~>` version requirement against the current
|
||||
major and minor version numbers of AshAuthentication.
|
||||
"""
|
||||
@spec generate_audience(Version.t()) :: String.t()
|
||||
def generate_audience(vsn) do
|
||||
"~> #{vsn.major}.#{vsn.minor}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
The validation function used to validate the "aud" claim.
|
||||
|
||||
Uses `Version.match?/2` to validate the provided claim against the current
|
||||
version. The use of `~>` means that tokens generated by versions of
|
||||
AshAuthentication with the the same major version and at least the same minor
|
||||
version should be compatible.
|
||||
"""
|
||||
@spec validate_audience(String.t(), any, any, Version.t()) :: boolean
|
||||
def validate_audience(claim, _, _, vsn) do
|
||||
Version.match?(vsn, Version.parse_requirement!(claim))
|
||||
end
|
||||
|
||||
@doc """
|
||||
The validation function used to the validate the "jti" claim.
|
||||
|
||||
This is done by checking that the token is valid with the token revocation
|
||||
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
|
||||
{:ok, revocation_resource} ->
|
||||
TokenRevocation.valid?(revocation_resource, jti)
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def validate_jti(_, _, _), do: false
|
||||
|
||||
@doc """
|
||||
The signer used to sign the token on a per-resource basis.
|
||||
"""
|
||||
@spec token_signer(Resource.t()) :: Signer.t()
|
||||
def token_signer(resource) do
|
||||
config = config(resource)
|
||||
|
||||
algorithm = Keyword.get_lazy(config, :signing_algorithm, &Jwt.default_algorithm/0)
|
||||
|
||||
case Keyword.fetch(config, :signing_secret) do
|
||||
{:ok, secret} ->
|
||||
Signer.create(algorithm, secret)
|
||||
|
||||
:error ->
|
||||
raise "Missing JWT signing secret. Please see the documentation for `AshAuthentication.Jwt` for details"
|
||||
end
|
||||
end
|
||||
|
||||
defp token_lifetime(config) do
|
||||
hours =
|
||||
config
|
||||
|> Keyword.get_lazy(:token_lifetime, &Jwt.default_lifetime_hrs/0)
|
||||
|
||||
hours * 60 * 60
|
||||
end
|
||||
|
||||
defp config(resource) do
|
||||
config =
|
||||
resource
|
||||
|> Info.tokens_options()
|
||||
|> Enum.reject(&is_nil(elem(&1, 1)))
|
||||
|
||||
:ash_authentication
|
||||
|> Application.get_env(Jwt, [])
|
||||
|> Keyword.merge(config)
|
||||
end
|
||||
end
|
175
lib/ash_authentication/password_authentication.ex
Normal file
175
lib/ash_authentication/password_authentication.ex
Normal file
|
@ -0,0 +1,175 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication do
|
||||
@password_authentication %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 actor'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 - which is 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 do
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.Users 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
|
||||
```
|
||||
|
||||
## DSL Documentation
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index([@password_authentication])}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc([@password_authentication])}
|
||||
"""
|
||||
|
||||
@behaviour AshAuthentication.Provider
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: [@password_authentication],
|
||||
transformers: [AshAuthentication.PasswordAuthentication.Transformer]
|
||||
|
||||
alias AshAuthentication.PasswordAuthentication
|
||||
alias Plug.Conn
|
||||
|
||||
@doc """
|
||||
Attempt to sign in an actor 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(module, map) :: {:ok, struct} | {:error, term}
|
||||
defdelegate sign_in_action(resource, attributes),
|
||||
to: PasswordAuthentication.Actions,
|
||||
as: :sign_in
|
||||
|
||||
@doc """
|
||||
Attempt to register an actor 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(module, map) :: {:ok, struct} | {:error, term}
|
||||
defdelegate register_action(resource, attributes),
|
||||
to: PasswordAuthentication.Actions,
|
||||
as: :register
|
||||
|
||||
@doc """
|
||||
Handle the request phase.
|
||||
|
||||
The password authentication provider does nothing with the request phase, and just returns
|
||||
the `conn` unmodified.
|
||||
"""
|
||||
@impl true
|
||||
@spec request_plug(Conn.t(), any) :: Conn.t()
|
||||
defdelegate request_plug(conn, config), to: PasswordAuthentication.Plug, as: :request
|
||||
|
||||
@doc """
|
||||
Handle the callback phase.
|
||||
|
||||
Handles both sign-in and registration actions via the same endpoint.
|
||||
"""
|
||||
@impl true
|
||||
@spec callback_plug(Conn.t(), any) :: Conn.t()
|
||||
defdelegate callback_plug(conn, config), to: PasswordAuthentication.Plug, as: :callback
|
||||
|
||||
@doc """
|
||||
Returns the name of the associated provider.
|
||||
"""
|
||||
@impl true
|
||||
@spec provides :: String.t()
|
||||
def provides, do: "password"
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec has_register_step?(any) :: boolean
|
||||
def has_register_step?(_), do: true
|
||||
end
|
52
lib/ash_authentication/password_authentication/actions.ex
Normal file
52
lib/ash_authentication/password_authentication/actions.ex
Normal file
|
@ -0,0 +1,52 @@
|
|||
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.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Query}
|
||||
alias AshAuthentication.PasswordAuthentication
|
||||
|
||||
@doc """
|
||||
Attempt to sign in an actor 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.sign_in_action_name(resource)
|
||||
{:ok, api} = AshAuthentication.Info.authentication_api(resource)
|
||||
|
||||
resource
|
||||
|> Query.for_read(action, attributes)
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, [actor]} -> {:ok, actor}
|
||||
{:ok, []} -> {:error, "Invalid username or password"}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempt to register an actor 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.register_action_name(resource)
|
||||
{:ok, api} = AshAuthentication.Info.authentication_api(resource)
|
||||
|
||||
resource
|
||||
|> Changeset.for_create(action, attributes)
|
||||
|> api.create()
|
||||
end
|
||||
end
|
|
@ -0,0 +1,24 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.GenerateTokenChange do
|
||||
@moduledoc """
|
||||
Given a successful registration, generate a token.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias Ash.{Changeset, Resource.Change}
|
||||
alias AshAuthentication.{Info, Jwt}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
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)
|
||||
{:ok, %{result | __metadata__: Map.put(result.__metadata__, :token, token)}}
|
||||
else
|
||||
{:ok, result}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,32 @@
|
|||
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_field(changeset.resource)
|
||||
{:ok, hash_field} = Info.hashed_password_field(changeset.resource)
|
||||
{:ok, hasher} = Info.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
|
96
lib/ash_authentication/password_authentication/html.ex
Normal file
96
lib/ash_authentication/password_authentication/html.ex
Normal file
|
@ -0,0 +1,96 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.HTML do
|
||||
@moduledoc """
|
||||
Renders a very basic forms for using password authentication.
|
||||
|
||||
These are mainly used for testing.
|
||||
"""
|
||||
|
||||
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="register" />
|
||||
<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 sign_in(module, options) :: String.t()
|
||||
def sign_in(resource, options) do
|
||||
resource
|
||||
|> build_assigns(options)
|
||||
|> render_sign_in()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render a basic HTML registration form.
|
||||
"""
|
||||
@spec register(module, options) :: String.t()
|
||||
def register(resource, options) do
|
||||
resource
|
||||
|> build_assigns(options)
|
||||
|> render_register()
|
||||
end
|
||||
|
||||
defp build_assigns(resource, options) do
|
||||
@defaults
|
||||
|> Keyword.merge(options)
|
||||
|> Map.new()
|
||||
|> Map.merge(PasswordAuthentication.Info.options(resource))
|
||||
|> Map.merge(AshAuthentication.Info.authentication_options(resource))
|
||||
end
|
||||
end
|
9
lib/ash_authentication/password_authentication/info.ex
Normal file
9
lib/ash_authentication/password_authentication/info.ex
Normal file
|
@ -0,0 +1,9 @@
|
|||
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]
|
||||
end
|
|
@ -0,0 +1,30 @@
|
|||
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.
|
||||
"""
|
||||
@spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()}
|
||||
def validate(changeset, _) do
|
||||
with true <- Info.confirmation_required?(changeset.resource),
|
||||
{:ok, password_field} <- Info.password_field(changeset.resource),
|
||||
{:ok, confirm_field} <- Info.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
|
55
lib/ash_authentication/password_authentication/plug.ex
Normal file
55
lib/ash_authentication/password_authentication/plug.ex
Normal file
|
@ -0,0 +1,55 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.Plug do
|
||||
@moduledoc """
|
||||
Handlers for incoming request and callback 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 actor in
|
||||
a single flow. This doesn't really work that well with `PasswordAuthentication` which has
|
||||
seperate "registration" and "sign-in" actions.
|
||||
|
||||
Here we simply ignore the request phase, which will cause an error to be
|
||||
returned to the remote user if they somehow find themselves there.
|
||||
|
||||
We use the "callback" phase to 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 request phase.
|
||||
|
||||
The password authentication provider does nothing with the request phase, and just returns
|
||||
the `conn` unmodified.
|
||||
"""
|
||||
@spec request(Conn.t(), any) :: Conn.t()
|
||||
def request(conn, _opts), do: conn
|
||||
|
||||
@doc """
|
||||
Handle the callback phase.
|
||||
|
||||
Handles both sign-in and registration actions via the same endpoint.
|
||||
"""
|
||||
@spec callback(Conn.t(), any) :: Conn.t()
|
||||
def callback(%{params: params, private: %{authenticator: config}} = conn, _opts) do
|
||||
params
|
||||
|> Map.get(to_string(config.subject_name), %{})
|
||||
|> do_action(config.resource)
|
||||
|> case do
|
||||
{:ok, actor} when is_struct(actor, config.resource) ->
|
||||
private_store(conn, {:success, actor})
|
||||
|
||||
{:error, changeset} ->
|
||||
private_store(conn, {:failure, changeset})
|
||||
end
|
||||
end
|
||||
|
||||
def callback(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)
|
||||
end
|
|
@ -0,0 +1,55 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do
|
||||
@moduledoc """
|
||||
Prepare a query for sign in
|
||||
|
||||
This preparation performs two jobs, one before the query executes and one
|
||||
after.
|
||||
|
||||
Firstly, it constrains the query to match the identity field pased on the
|
||||
identity argument passed to the action.
|
||||
|
||||
Secondly, it validates the supplied password using the configured hash
|
||||
provider, and if correct allows the record to be returned, otherwise
|
||||
returns an empty result.
|
||||
"""
|
||||
use Ash.Resource.Preparation
|
||||
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, PasswordAuthentication.Info}
|
||||
alias Ash.{Query, Resource.Preparation}
|
||||
require Ash.Query
|
||||
|
||||
@impl true
|
||||
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
|
||||
def prepare(query, _opts, _) do
|
||||
{:ok, identity_field} = Info.identity_field(query.resource)
|
||||
{:ok, password_field} = Info.password_field(query.resource)
|
||||
{:ok, hasher} = Info.hash_provider(query.resource)
|
||||
|
||||
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)
|
||||
|
||||
if hasher.valid?(password, record.hashed_password),
|
||||
do: {:ok, [maybe_generate_token(record)]},
|
||||
else: auth_failed(query)
|
||||
|
||||
_, _ ->
|
||||
hasher.simulate()
|
||||
auth_failed(query)
|
||||
end)
|
||||
end
|
||||
|
||||
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)
|
||||
%{record | __metadata__: Map.put(record.__metadata__, :token, token)}
|
||||
else
|
||||
record
|
||||
end
|
||||
end
|
||||
end
|
198
lib/ash_authentication/password_authentication/transformer.ex
Normal file
198
lib/ash_authentication/password_authentication/transformer.ex
Normal file
|
@ -0,0 +1,198 @@
|
|||
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 `PasswordAuthentication.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.{
|
||||
GenerateTokenChange,
|
||||
HashPasswordChange,
|
||||
Info,
|
||||
PasswordConfirmationValidation,
|
||||
SignInPreparation
|
||||
}
|
||||
|
||||
alias Ash.{Resource, Type}
|
||||
alias Spark.Dsl.Transformer
|
||||
import AshAuthentication.PasswordAuthentication.UserValidations
|
||||
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_authentication_extension(dsl_state),
|
||||
{:ok, dsl_state} <- validate_identity_field(dsl_state),
|
||||
{:ok, dsl_state} <- validate_hashed_password_field(dsl_state),
|
||||
{:ok, dsl_state} <- maybe_build_action(dsl_state, :register, &build_register_action/1),
|
||||
{:ok, dsl_state} <- validate_register_action(dsl_state),
|
||||
{:ok, dsl_state} <- maybe_build_action(dsl_state, :sign_in, &build_sign_in_action/1),
|
||||
{: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) do
|
||||
with {:ok, hashed_password_field} <- Info.hashed_password_field(dsl_state),
|
||||
{:ok, password_field} <- Info.password_field(dsl_state),
|
||||
{:ok, confirm_field} <- Info.password_confirmation_field(dsl_state),
|
||||
confirmation_required? <- Info.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: :register,
|
||||
arguments: arguments,
|
||||
changes: changes,
|
||||
allow_nil_input: [hashed_password_field]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def build_sign_in_action(dsl_state) do
|
||||
with {:ok, identity_field} <- Info.identity_field(dsl_state),
|
||||
{:ok, password_field} <- Info.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: :sign_in,
|
||||
arguments: arguments,
|
||||
preparations: preparations,
|
||||
get?: true
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,191 @@
|
|||
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.HashProvider
|
||||
|
||||
alias AshAuthentication.PasswordAuthentication.{
|
||||
GenerateTokenChange,
|
||||
HashPasswordChange,
|
||||
Info,
|
||||
PasswordConfirmationValidation,
|
||||
SignInPreparation
|
||||
}
|
||||
|
||||
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
|
||||
import AshAuthentication.Validations
|
||||
import AshAuthentication.Validations.Action
|
||||
import AshAuthentication.Validations.Attribute
|
||||
|
||||
@doc """
|
||||
Validates at the `AshAuthentication` extension is also present on the resource.
|
||||
"""
|
||||
@spec validate_authentication_extension(Dsl.t()) :: :ok | {:error, Exception.t()}
|
||||
def validate_authentication_extension(dsl_state) do
|
||||
extensions = Transformer.get_persisted(dsl_state, :extensions, [])
|
||||
|
||||
if AshAuthentication in extensions,
|
||||
do: :ok,
|
||||
else:
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:extensions],
|
||||
message:
|
||||
"The `AshAuthentication` extension must also be present on this resource for password authentication to work."
|
||||
)}
|
||||
end
|
||||
|
||||
@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.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.identity_field(dsl_state),
|
||||
{:ok, password_field} <- Info.password_field(dsl_state),
|
||||
{:ok, action} <- validate_action_exists(dsl_state, :sign_in),
|
||||
: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_field(dsl_state),
|
||||
{:ok, password_confirmation_field} <- Info.password_confirmation_field(dsl_state),
|
||||
{:ok, hashed_password_field} <- Info.hashed_password_field(dsl_state),
|
||||
confirmation_required? <- Info.confirmation_required?(dsl_state),
|
||||
{:ok, action} <- validate_action_exists(dsl_state, :register),
|
||||
: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.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, :allow_nil?, [false]),
|
||||
: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
|
194
lib/ash_authentication/plug.ex
Normal file
194
lib/ash_authentication/plug.ex
Normal file
|
@ -0,0 +1,194 @@
|
|||
defmodule AshAuthentication.Plug do
|
||||
@moduledoc ~S"""
|
||||
Generate an authentication plug.
|
||||
|
||||
Use in your app by creating a new module called `AuthPlug` or similar:
|
||||
|
||||
```elixir
|
||||
defmodule MyAppWeb.AuthPlug do
|
||||
use AshAuthentication.Plug, otp_app: :my_app
|
||||
|
||||
def handle_success(conn, user, _token) do
|
||||
conn
|
||||
|> store_in_session(user)
|
||||
|> send_resp(200, "Welcome back #{user.name})
|
||||
end
|
||||
|
||||
def handle_failure(conn) do
|
||||
conn
|
||||
|> send_resp(401, "Better luck next time")
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Using in Phoenix
|
||||
|
||||
In your Phoenix router you can add it:
|
||||
|
||||
```elixir
|
||||
scope "/auth" do
|
||||
pipe_through :browser
|
||||
forward "/", MyAppWeb.AuthPlug
|
||||
end
|
||||
```
|
||||
|
||||
In order to load any authenticated actors for either web or API users you can add the following to your router:
|
||||
|
||||
```elixir
|
||||
import MyAppWeb.AuthPlug
|
||||
|
||||
pipeline :session_users do
|
||||
pipe :load_from_session
|
||||
end
|
||||
|
||||
pipeline :bearer_users do
|
||||
pipe :load_from_bearer
|
||||
end
|
||||
|
||||
scope "/", MyAppWeb do
|
||||
pipe_through [:browser, :session_users]
|
||||
|
||||
live "/", PageLive, :home
|
||||
end
|
||||
|
||||
scope "/api", MyAppWeb do
|
||||
pipe_through [:api, :bearer_users]
|
||||
|
||||
get "/" ApiController, :index
|
||||
end
|
||||
```
|
||||
### Using in a Plug application
|
||||
|
||||
```elixir
|
||||
use Plug.Router
|
||||
|
||||
forward "/auth", to: MyAppWeb.AuthPlug
|
||||
```
|
||||
|
||||
Note that you will need to include a bunch of other plugs in the pipeline to
|
||||
do useful things like session and query param fetching.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Resource}
|
||||
alias AshAuthentication.Plug.Helpers
|
||||
alias Plug.Conn
|
||||
|
||||
@type authenticator_config :: %{
|
||||
api: module,
|
||||
provider: module,
|
||||
resource: module,
|
||||
subject: atom
|
||||
}
|
||||
|
||||
@doc """
|
||||
When authentication has been succesful, this callback will be called with the
|
||||
conn, the authenticated resource and a token.
|
||||
|
||||
This allows you to choose what action to take as appropriate for your
|
||||
application.
|
||||
|
||||
The default implementation calls `store_in_session/2` and returns a simple
|
||||
"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()
|
||||
|
||||
@doc """
|
||||
When there is any failure during authentication this callback is called.
|
||||
|
||||
Note that this includes not just authentication failures, but even simple
|
||||
404s.
|
||||
|
||||
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()) :: Conn.t()
|
||||
|
||||
defmacro __using__(opts) do
|
||||
otp_app =
|
||||
opts
|
||||
|> Keyword.fetch!(:otp_app)
|
||||
|> Macro.expand_literal(__ENV__)
|
||||
|
||||
AshAuthentication.Validations.validate_unique_subject_names(otp_app)
|
||||
|
||||
quote generated: true do
|
||||
@behaviour AshAuthentication.Plug
|
||||
import Plug.Conn
|
||||
|
||||
defmodule Router do
|
||||
@moduledoc """
|
||||
The Authentication Router.
|
||||
|
||||
Plug this into your app's router using:
|
||||
|
||||
```elixir
|
||||
forward "/auth", to: #{__MODULE__}
|
||||
```
|
||||
|
||||
This router is generated using `AshAuthentication.Plug.Router`.
|
||||
"""
|
||||
use AshAuthentication.Plug.Router,
|
||||
otp_app: unquote(otp_app),
|
||||
return_to:
|
||||
__MODULE__
|
||||
|> Module.split()
|
||||
|> List.delete_at(-1)
|
||||
|> Module.concat()
|
||||
end
|
||||
|
||||
@doc """
|
||||
The default implementation of `handle_success/3`.
|
||||
|
||||
Calls `AshAuthentication.Plug.Helpers.store_in_session/2` then sends a
|
||||
basic 200 response.
|
||||
"""
|
||||
@spec handle_success(Conn.t(), Resource.record(), token :: String.t()) ::
|
||||
Conn.t()
|
||||
def handle_success(conn, actor, _token) do
|
||||
conn
|
||||
|> store_in_session(actor)
|
||||
|> send_resp(200, "Access granted")
|
||||
end
|
||||
|
||||
@doc """
|
||||
The default implementation of `handle_failure/1`.
|
||||
|
||||
Sends a very basic 401 response.
|
||||
"""
|
||||
@spec handle_failure(Conn.t(), nil | Changeset.t()) :: Conn.t()
|
||||
def handle_failure(conn, _) do
|
||||
conn
|
||||
|> send_resp(401, "Access denied")
|
||||
end
|
||||
|
||||
defoverridable handle_success: 3, handle_failure: 2
|
||||
|
||||
@doc """
|
||||
Store an actor in the session.
|
||||
"""
|
||||
@spec store_in_session(Conn.t(), Resource.record()) :: Conn.t()
|
||||
def store_in_session(conn, actor),
|
||||
do: Helpers.store_in_session(conn, actor)
|
||||
|
||||
@doc """
|
||||
Attempt to retrieve all actors from the connections' session.
|
||||
|
||||
A wrapper around `AshAuthentication.Plug.Helpers.retrieve_from_session/2`
|
||||
with the `otp_app` already present.
|
||||
"""
|
||||
@spec load_from_session(Conn.t(), any) :: Conn.t()
|
||||
def load_from_session(conn, _opts),
|
||||
do: Helpers.retrieve_from_session(conn, unquote(otp_app))
|
||||
|
||||
@doc """
|
||||
Attempt to retrieve actors from the `Authorization` header(s).
|
||||
|
||||
A wrapper around `AshAuthentication.Plug.Helpers.retrieve_from_bearer/2` with the `otp_app` already present.
|
||||
"""
|
||||
@spec load_from_bearer(Conn.t(), any) :: Conn.t()
|
||||
def load_from_bearer(conn, _opts),
|
||||
do: Helpers.retrieve_from_bearer(conn, unquote(otp_app))
|
||||
end
|
||||
end
|
||||
end
|
56
lib/ash_authentication/plug/dispatcher.ex
Normal file
56
lib/ash_authentication/plug/dispatcher.ex
Normal file
|
@ -0,0 +1,56 @@
|
|||
defmodule AshAuthentication.Plug.Dispatcher do
|
||||
@moduledoc """
|
||||
Route requests and callbacks to the correct provider plugs.
|
||||
"""
|
||||
|
||||
@behaviour Plug
|
||||
alias Plug.Conn
|
||||
|
||||
@type config :: {:request | :callback, [AshAuthentication.Plug.authenticator_config()], module}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init([config]) :: config
|
||||
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.
|
||||
"""
|
||||
@impl true
|
||||
@spec call(Conn.t(), config | any) :: Conn.t()
|
||||
def call(
|
||||
%{params: %{"subject_name" => subject_name, "provider" => provider}} = conn,
|
||||
{phase, routes, return_to}
|
||||
) do
|
||||
conn =
|
||||
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
|
||||
|
||||
_ ->
|
||||
conn
|
||||
end
|
||||
|
||||
case conn do
|
||||
%{state: :sent} ->
|
||||
conn
|
||||
|
||||
%{private: %{authentication_result: {:success, actor}}} ->
|
||||
return_to.handle_success(conn, actor, Map.get(actor.__metadata__, :token))
|
||||
|
||||
%{private: %{authentication_result: {:failure, reason}}} ->
|
||||
return_to.handle_failure(conn, reason)
|
||||
|
||||
_ ->
|
||||
return_to.handle_failure(conn, nil)
|
||||
end
|
||||
end
|
||||
|
||||
def call(conn, {_phase, _routes, return_to}), do: return_to.handle_failure(conn)
|
||||
end
|
139
lib/ash_authentication/plug/helpers.ex
Normal file
139
lib/ash_authentication/plug/helpers.ex
Normal file
|
@ -0,0 +1,139 @@
|
|||
defmodule AshAuthentication.Plug.Helpers do
|
||||
@moduledoc """
|
||||
Authentication helpers for use in your router, etc.
|
||||
"""
|
||||
alias Ash.{Changeset, Error, Resource}
|
||||
alias AshAuthentication.{Info, Jwt, TokenRevocation}
|
||||
alias Plug.Conn
|
||||
|
||||
@doc """
|
||||
Store the actor in the connections' session.
|
||||
"""
|
||||
@spec store_in_session(Conn.t(), Resource.record()) :: Conn.t()
|
||||
def store_in_session(conn, actor) do
|
||||
subject_name = AshAuthentication.Info.authentication_subject_name!(actor.__struct__)
|
||||
subject = AshAuthentication.resource_to_subject(actor)
|
||||
|
||||
Conn.put_session(conn, subject_name, subject)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Given a list of subjects, turn as many as possible into actors.
|
||||
"""
|
||||
@spec load_subjects([AshAuthentication.subject()], module) :: map
|
||||
def load_subjects(subjects, otp_app) when is_list(subjects) do
|
||||
configurations =
|
||||
otp_app
|
||||
|> AshAuthentication.authenticated_resources()
|
||||
|> Stream.map(&{to_string(&1.subject_name), &1})
|
||||
|
||||
subjects
|
||||
|> Enum.reduce(%{}, fn subject, result ->
|
||||
subject = URI.parse(subject)
|
||||
|
||||
with {:ok, config} <- Map.fetch(configurations, subject.path),
|
||||
{:ok, actor} <- AshAuthentication.subject_to_resource(subject, config) do
|
||||
current_subject_name = current_subject_name(config.subject_name)
|
||||
Map.put(result, current_subject_name, actor)
|
||||
else
|
||||
_ -> result
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempt to retrieve all actors from the connections' session.
|
||||
|
||||
Iterates through all configured authentication resources for `otp_app` and
|
||||
retrieves any actors stored in the session, loads them and stores them in the
|
||||
assigns under their subject name (with the prefix `current_`).
|
||||
|
||||
If there is no actor present for a resource then the assign is set to `nil`.
|
||||
"""
|
||||
@spec retrieve_from_session(Conn.t(), module) :: Conn.t()
|
||||
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)
|
||||
|
||||
with subject when is_binary(subject) <- Conn.get_session(conn, config.subject_name),
|
||||
{:ok, actor} <- AshAuthentication.subject_to_resource(subject, config) do
|
||||
Conn.assign(conn, current_subject_name, actor)
|
||||
else
|
||||
_ ->
|
||||
Conn.assign(conn, current_subject_name, nil)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validate authorization header(s).
|
||||
|
||||
Assumes that your clients are sending a bearer-style authorization header with
|
||||
your request. If a valid bearer token is present then the subject is loaded
|
||||
into the assigns.
|
||||
"""
|
||||
@spec retrieve_from_bearer(Conn.t(), module) :: Conn.t()
|
||||
def retrieve_from_bearer(conn, otp_app) do
|
||||
conn
|
||||
|> Conn.get_req_header("authorization")
|
||||
|> 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, actor} <- AshAuthentication.subject_to_resource(subject, config),
|
||||
current_subject_name <- current_subject_name(config.subject_name) do
|
||||
Conn.assign(conn, current_subject_name, actor)
|
||||
else
|
||||
_ -> conn
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Revoke all authorization header(s).
|
||||
|
||||
Any bearer-style authorization headers will have their tokens revoked.
|
||||
"""
|
||||
@spec revoke_bearer_tokens(Conn.t(), module) :: Conn.t()
|
||||
def revoke_bearer_tokens(conn, otp_app) do
|
||||
conn
|
||||
|> Conn.get_req_header("authorization")
|
||||
|> 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),
|
||||
:ok <- TokenRevocation.revoke(revocation_resource, token) do
|
||||
conn
|
||||
else
|
||||
_ -> conn
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Dyanamically generated atoms are generally frowned upon, but in this case
|
||||
# the `subject_name` is a statically configured atom, so should be fine.
|
||||
defp current_subject_name(subject_name) when is_atom(subject_name),
|
||||
do: String.to_atom("current_#{subject_name}")
|
||||
|
||||
@doc """
|
||||
Store result in private.
|
||||
|
||||
This is used by authentication plug handlers to store their result for passing
|
||||
back to the dispatcher.
|
||||
"""
|
||||
@spec private_store(
|
||||
Conn.t(),
|
||||
{:success, Resource.record()} | {:failure, nil | Changeset.t() | Error.t()}
|
||||
) ::
|
||||
Conn.t()
|
||||
def private_store(conn, {:success, record})
|
||||
when is_struct(record, conn.private.authenticator.resource),
|
||||
do: Conn.put_private(conn, :authentication_result, {:success, record})
|
||||
|
||||
def private_store(conn, {:failure, reason})
|
||||
when is_nil(reason) or is_map(reason),
|
||||
do: Conn.put_private(conn, :authentication_result, {:failure, reason})
|
||||
end
|
65
lib/ash_authentication/plug/router.ex
Normal file
65
lib/ash_authentication/plug/router.ex
Normal file
|
@ -0,0 +1,65 @@
|
|||
defmodule AshAuthentication.Plug.Router do
|
||||
@moduledoc """
|
||||
Dynamically generates the authentication router for the authentication
|
||||
requests and callbacks.
|
||||
|
||||
Used internally by `AshAuthentication.Plug`.
|
||||
"""
|
||||
|
||||
@doc false
|
||||
@spec __using__(keyword) :: Macro.t()
|
||||
defmacro __using__(opts) do
|
||||
otp_app =
|
||||
opts
|
||||
|> Keyword.fetch!(:otp_app)
|
||||
|> Macro.expand_literal(__ENV__)
|
||||
|
||||
return_to =
|
||||
opts
|
||||
|> Keyword.fetch!(:return_to)
|
||||
|> Macro.expand_literal(__ENV__)
|
||||
|
||||
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}
|
||||
end)
|
||||
end)
|
||||
|> Map.new()
|
||||
|> Macro.escape()
|
||||
|
||||
quote generated: true do
|
||||
use Plug.Router
|
||||
plug(:match)
|
||||
plug(:dispatch)
|
||||
|
||||
match("/:subject_name/:provider",
|
||||
to: AshAuthentication.Plug.Dispatcher,
|
||||
init_opts: [{:request, unquote(routes), unquote(return_to)}]
|
||||
)
|
||||
|
||||
match("/:subject_name/:provider/callback",
|
||||
to: AshAuthentication.Plug.Dispatcher,
|
||||
init_opts: [{:callback, unquote(routes), unquote(return_to)}]
|
||||
)
|
||||
|
||||
match(_,
|
||||
to: AshAuthentication.Plug.Dispatcher,
|
||||
init_opts: [{:noop, [], unquote(return_to)}]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
40
lib/ash_authentication/provider.ex
Normal file
40
lib/ash_authentication/provider.ex
Normal file
|
@ -0,0 +1,40 @@
|
|||
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() :: 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(), AshAuthentication.resource_config()) :: Conn.t()
|
||||
|
||||
@doc """
|
||||
A function plug which can handle the request phase.
|
||||
"""
|
||||
@callback request_plug(Conn.t(), AshAuthentication.resource_config()) :: Conn.t()
|
||||
end
|
172
lib/ash_authentication/token_revocation.ex
Normal file
172
lib/ash_authentication/token_revocation.ex
Normal file
|
@ -0,0 +1,172 @@
|
|||
defmodule AshAuthentication.TokenRevocation do
|
||||
@revocation %Spark.Dsl.Section{
|
||||
name: :revocation,
|
||||
describe: "Configure revocation options for this resource",
|
||||
schema: [
|
||||
api: [
|
||||
type: {:behaviour, Ash.Api},
|
||||
doc: """
|
||||
The Ash API to use to access this resource.
|
||||
""",
|
||||
required: true
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@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.
|
||||
|
||||
## Storage
|
||||
|
||||
Token revocations are ephemeral, but their lifetime directly correlates to the
|
||||
lifetime of your tokens - ie if you have a long expiry time on your tokens you
|
||||
have to keep the revation records for longer. Therefore we suggest a (semi)
|
||||
permanent data layer, such as Postgres.
|
||||
|
||||
## Usage
|
||||
|
||||
There is no need to define any attributes, etc. The extension will generate
|
||||
them all for you. As there is no other use-case for this resource, it's
|
||||
unlikely that you will need to customise it.
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.TokenRevocation do
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication.TokenRevocation]
|
||||
|
||||
revocation do
|
||||
api(MyApp.Api)
|
||||
end
|
||||
|
||||
postgres do
|
||||
table("token_revocations")
|
||||
repo(MyApp.Repo)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Whilst it's possible to have multiple token revocation resources, in practice
|
||||
there is no need to.
|
||||
|
||||
## Dsl
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index([@revocation])}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc([@revocation])}
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: [@revocation],
|
||||
transformers: [AshAuthentication.TokenRevocation.Transformer]
|
||||
|
||||
alias AshAuthentication.TokenRevocation.Info
|
||||
alias Ash.{Changeset, DataLayer, Query, Resource}
|
||||
|
||||
@doc """
|
||||
Revoke a token.
|
||||
"""
|
||||
@spec revoke(Resource.t(), token :: String.t()) :: :ok | {:error, any}
|
||||
def revoke(resource, token) do
|
||||
with {:ok, api} <- Info.api(resource) do
|
||||
resource
|
||||
|> Changeset.for_create(:revoke_token, %{token: token})
|
||||
|> api.create(upsert?: true)
|
||||
|> case do
|
||||
{:ok, _} -> :ok
|
||||
{:ok, _, _} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find out if (via it's JTI) a token has been revoked?
|
||||
"""
|
||||
@spec revoked?(Resource.t(), jti :: String.t()) :: boolean
|
||||
def revoked?(resource, jti) do
|
||||
with {:ok, api} <- Info.api(resource) do
|
||||
resource
|
||||
|> Query.for_read(:revoked, %{jti: jti})
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, []} -> false
|
||||
_ -> true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
The opposite of `revoked?/2`
|
||||
"""
|
||||
@spec valid?(Resource.t(), jti :: String.t()) :: boolean
|
||||
def valid?(resource, jti), do: not revoked?(resource, jti)
|
||||
|
||||
@doc """
|
||||
Expunge expired revocations.
|
||||
|
||||
## Note
|
||||
|
||||
Sadly this function iterates over all expired revocations and delete 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.
|
||||
|
||||
Luckily, this function is only run periodically, so it shouldn't be a huge
|
||||
cost. Contact the maintainers if it becomes a problem for you.
|
||||
"""
|
||||
@spec expunge(Resource.t()) :: :ok | {:error, any}
|
||||
def expunge(resource) do
|
||||
DataLayer.transaction(
|
||||
resource,
|
||||
fn ->
|
||||
with {:ok, api} <- Info.api(resource),
|
||||
query <- Query.for_read(resource, :expired),
|
||||
{:ok, expired} <- api.read(query) do
|
||||
expired
|
||||
|> Stream.map(&remove_revocation/1)
|
||||
|> Enum.reduce_while(:ok, fn
|
||||
:ok, _ -> {:cont, :ok}
|
||||
{:error, reason}, _ -> {:halt, {:error, reason}}
|
||||
end)
|
||||
end
|
||||
end,
|
||||
5000
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a revocation.
|
||||
|
||||
## Warning
|
||||
|
||||
If the revocation in question is not yet expired, then this has the effect of
|
||||
making this token valid again.
|
||||
|
||||
You are unlikely to need to do this, as `AshAuthentication` will periodically
|
||||
remove all expired revocations automatically, however it is provided here in
|
||||
case you need it.
|
||||
"""
|
||||
@spec remove_revocation(Resource.record()) :: :ok | {:error, any}
|
||||
def remove_revocation(revocation) do
|
||||
with {:ok, api} <- Info.api(revocation.__struct__) do
|
||||
revocation
|
||||
|> Changeset.for_destroy(:expire)
|
||||
|> api.destroy()
|
||||
|> case do
|
||||
:ok -> :ok
|
||||
{:ok, _} -> :ok
|
||||
{:ok, _, _} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
53
lib/ash_authentication/token_revocation/expunger.ex
Normal file
53
lib/ash_authentication/token_revocation/expunger.ex
Normal file
|
@ -0,0 +1,53 @@
|
|||
defmodule AshAuthentication.TokenRevocation.Expunger do
|
||||
@default_period_hrs 12
|
||||
|
||||
@moduledoc """
|
||||
A genserver which periodically removes expired token revocations.
|
||||
|
||||
Scans all token revocation resources every #{@default_period_hrs} hours and removes
|
||||
any expired token revocations.
|
||||
|
||||
You can change the expunger period by configuring it in your application
|
||||
environment:
|
||||
|
||||
```elixir
|
||||
config :ash_authentication, #{inspect(__MODULE__)},
|
||||
period_hrs: #{@default_period_hrs}
|
||||
```
|
||||
|
||||
This server is started automatically as part of the `:ash_authentication`
|
||||
supervision tree.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
alias AshAuthentication.TokenRevocation
|
||||
|
||||
@doc false
|
||||
@spec start_link(any) :: GenServer.on_start()
|
||||
def start_link(opts), do: GenServer.start_link(__MODULE__, opts)
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init(any) :: {:ok, :timer.tref()}
|
||||
def init(_) do
|
||||
period =
|
||||
:ash_authentication
|
||||
|> Application.get_env(__MODULE__, [])
|
||||
|> Keyword.get(:period_hrs, @default_period_hrs)
|
||||
|> then(&(&1 * 60 * 60 * 1000))
|
||||
|
||||
:timer.send_interval(period, :expunge)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_info(:expunge, tref) do
|
||||
:code.all_loaded()
|
||||
|> Stream.map(&elem(&1, 0))
|
||||
|> Stream.filter(&function_exported?(&1, :spark_dsl_config, 0))
|
||||
|> Stream.filter(&(TokenRevocation in Spark.extensions(&1)))
|
||||
|> Enum.each(&TokenRevocation.expunge/1)
|
||||
|
||||
{:noreply, tref}
|
||||
end
|
||||
end
|
9
lib/ash_authentication/token_revocation/info.ex
Normal file
9
lib/ash_authentication/token_revocation/info.ex
Normal file
|
@ -0,0 +1,9 @@
|
|||
defmodule AshAuthentication.TokenRevocation.Info do
|
||||
@moduledoc """
|
||||
Generated configuration functions based on a resource's DSL configuration
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication.TokenRevocation,
|
||||
sections: [:revocation]
|
||||
end
|
|
@ -0,0 +1,32 @@
|
|||
defmodule AshAuthentication.TokenRevocation.RevokeTokenChange do
|
||||
@moduledoc """
|
||||
Decode the passed in token and build a revocation based on it's claims.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
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 ->
|
||||
changeset
|
||||
|> Changeset.get_argument(:token)
|
||||
|> Joken.peek_claims()
|
||||
|> case do
|
||||
{:ok, %{"jti" => jti, "exp" => exp}} ->
|
||||
expires_at =
|
||||
exp
|
||||
|> DateTime.from_unix!()
|
||||
|
||||
changeset
|
||||
|> Changeset.change_attributes(jti: jti, expires_at: expires_at)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, InvalidArgument.exception(field: :token, message: to_string(reason))}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
212
lib/ash_authentication/token_revocation/transformer.ex
Normal file
212
lib/ash_authentication/token_revocation/transformer.ex
Normal file
|
@ -0,0 +1,212 @@
|
|||
defmodule AshAuthentication.TokenRevocation.Transformer do
|
||||
@moduledoc """
|
||||
The token revocation transformer
|
||||
|
||||
Sets up the default schema and actions for the token revocation resource.
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
require Ash.Expr
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.TokenRevocation
|
||||
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?(any) :: boolean()
|
||||
def after?(Ash.Resource.Transformers.ValidatePrimaryActions), do: true
|
||||
def after?(_), do: false
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec before?(any) :: boolean
|
||||
def before?(Ash.Resource.Transformers.CachePrimaryKey), do: true
|
||||
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
|
||||
with {:ok, _api} <- validate_api_presence(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_add_field(dsl_state, :jti, :string,
|
||||
primary_key?: true,
|
||||
allow_nil?: false,
|
||||
sensitive?: true,
|
||||
writable?: true
|
||||
),
|
||||
:ok <- validate_jti_field(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_add_field(dsl_state, :expires_at, :utc_datetime,
|
||||
allow_nil?: false,
|
||||
writable?: true
|
||||
),
|
||||
:ok <- validate_expires_at_field(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(dsl_state, :revoke_token, &build_create_revoke_token_action/1),
|
||||
:ok <- validate_revoke_token_action(dsl_state),
|
||||
{:ok, dsl_state} <- maybe_build_action(dsl_state, :read, &build_read_revoked_action/1),
|
||||
:ok <- validate_read_revoked_action(dsl_state),
|
||||
{:ok, dsl_state} <- maybe_build_action(dsl_state, :read, &build_read_expired_action/1),
|
||||
:ok <- validate_read_expired_action(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(dsl_state, :destroy, &build_destroy_expire_action/1),
|
||||
:ok <- validate_destroy_expire_action(dsl_state) do
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_create_revoke_token_action(_dsl_state) do
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :argument,
|
||||
name: :token,
|
||||
type: :string,
|
||||
allow_nil?: false,
|
||||
sensitive?: true
|
||||
)
|
||||
]
|
||||
|
||||
changes = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
|
||||
change: TokenRevocation.RevokeTokenChange
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :create,
|
||||
name: :revoke_token,
|
||||
primary?: true,
|
||||
arguments: arguments,
|
||||
changes: changes,
|
||||
accept: []
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_revoke_token_action(dsl_state) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, :revoke_token),
|
||||
:ok <- validate_token_argument(action) do
|
||||
validate_action_has_change(action, TokenRevocation.RevokeTokenChange)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_token_argument(action) do
|
||||
with :ok <-
|
||||
validate_action_argument_option(action, :token, :type, [Ash.Type.String, :string]),
|
||||
:ok <- validate_action_argument_option(action, :token, :allow_nil?, [false]) do
|
||||
validate_action_argument_option(action, :token, :sensitive?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
defp build_read_revoked_action(_dsl_state) do
|
||||
import Ash.Filter.TemplateHelpers
|
||||
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||
name: :jti,
|
||||
type: :string,
|
||||
allow_nil?: false,
|
||||
sensitive?: true
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: :revoked,
|
||||
get?: true,
|
||||
filter: expr(jti == ^arg(:jti)),
|
||||
arguments: arguments
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_read_revoked_action(dsl_state) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, :revoked),
|
||||
:ok <- validate_action_argument_option(action, :jti, :type, [Ash.Type.String, :string]),
|
||||
:ok <- validate_action_argument_option(action, :jti, :allow_nil?, [false]) do
|
||||
validate_action_argument_option(action, :jti, :sensitive?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
defp build_read_expired_action(_dsl_state) do
|
||||
import Ash.Filter.TemplateHelpers
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: :expired,
|
||||
get?: true,
|
||||
filter: expr(expires_at < now())
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_read_expired_action(dsl_state) do
|
||||
with {:ok, _} <- validate_action_exists(dsl_state, :expired) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp build_destroy_expire_action(_dsl_state),
|
||||
do:
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :destroy, name: :expire, primary?: true)
|
||||
|
||||
defp validate_destroy_expire_action(dsl_state) do
|
||||
with {:ok, _} <- validate_action_exists(dsl_state, :expire) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_field(dsl_state, name, type, options) do
|
||||
if Resource.Info.attribute(dsl_state, name) do
|
||||
{:ok, dsl_state}
|
||||
else
|
||||
options =
|
||||
options
|
||||
|> Keyword.put(:name, name)
|
||||
|> Keyword.put(:type, type)
|
||||
|
||||
attribute = Transformer.build_entity!(Resource.Dsl, [:attributes], :attribute, options)
|
||||
|
||||
{:ok, Transformer.add_entity(dsl_state, [:attributes], attribute)}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_jti_field(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, :jti),
|
||||
:ok <- validate_attribute_option(attribute, resource, :type, [Ash.Type.String, :string]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :sensitive?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :primary_key?, [true]) do
|
||||
validate_attribute_option(attribute, resource, :private?, [false])
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_expires_at_field(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, :expires_at),
|
||||
:ok <-
|
||||
validate_attribute_option(attribute, resource, :type, [
|
||||
Ash.Type.UtcDatetime,
|
||||
:utc_datetime
|
||||
]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]) do
|
||||
validate_attribute_option(attribute, resource, :writable?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_api_presence(dsl_state) do
|
||||
case Transformer.get_option(dsl_state, [:revocation], :api) do
|
||||
nil ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:revocation, :api],
|
||||
message: "An API module must be present"
|
||||
)}
|
||||
|
||||
api ->
|
||||
{:ok, api}
|
||||
end
|
||||
end
|
||||
end
|
155
lib/ash_authentication/transformer.ex
Normal file
155
lib/ash_authentication/transformer.ex
Normal file
|
@ -0,0 +1,155 @@
|
|||
defmodule AshAuthentication.Transformer do
|
||||
@moduledoc """
|
||||
The Authentication transformer
|
||||
|
||||
Sets up non-provider-specific confiration for authenticated resources.
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
alias AshAuthentication.Info
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
import AshAuthentication.Validations.Action
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec after?(any) :: boolean()
|
||||
def after?(Ash.Resource.Transformers.ValidatePrimaryActions), do: true
|
||||
def after?(_), do: false
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec before?(any) :: boolean
|
||||
def before?(Ash.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
|
||||
with {:ok, api} <- validate_api_presence(dsl_state),
|
||||
:ok <- validate_at_least_one_authentication_provider(dsl_state),
|
||||
{:ok, get_by_subject_action_name} <-
|
||||
Info.authentication_get_by_subject_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
get_by_subject_action_name,
|
||||
&build_get_by_subject_action/1
|
||||
),
|
||||
: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}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_get_by_subject_action(dsl_state) do
|
||||
with {:ok, get_by_subject_action_name} <-
|
||||
Info.authentication_get_by_subject_action_name(dsl_state) do
|
||||
Transformer.build_entity(Ash.Resource.Dsl, [:actions], :read,
|
||||
name: get_by_subject_action_name,
|
||||
get?: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp find_or_generate_subject_name(dsl_state) do
|
||||
with nil <- Transformer.get_option(dsl_state, [:authentication], :subject_name),
|
||||
nil <- Transformer.get_option(dsl_state, [:resource], :short_name) do
|
||||
# We have to do this because the resource has not yet been compiled, so we can't call `default_short_name/0`.
|
||||
dsl_state
|
||||
|> Transformer.get_persisted(:module)
|
||||
|> Module.split()
|
||||
|> List.last()
|
||||
|> Macro.underscore()
|
||||
|> String.to_atom()
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_token_revocation_resource(dsl_state) do
|
||||
if Transformer.get_option(dsl_state, [:tokens], :enabled?) do
|
||||
with resource when not is_nil(resource) <-
|
||||
Transformer.get_option(dsl_state, [:tokens], :revocation_resource),
|
||||
Ash.Resource <- resource.spark_is(),
|
||||
true <- AshAuthentication.TokenRevocation in Spark.extensions(resource) do
|
||||
:ok
|
||||
else
|
||||
nil ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:tokens, :revocation_resource],
|
||||
message: "A revocation resource must be configured when tokens are enabled"
|
||||
)}
|
||||
|
||||
_ ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:tokens, :revocation_resource],
|
||||
message:
|
||||
"The revocation resource must be an Ash resource with the `AshAuthentication.TokenRevocation` extension"
|
||||
)}
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_api_presence(dsl_state) do
|
||||
case Transformer.get_option(dsl_state, [:authentication], :api) do
|
||||
nil ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:authentication, :api],
|
||||
message: "An API module must be present"
|
||||
)}
|
||||
|
||||
api ->
|
||||
{:ok, api}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_at_least_one_authentication_provider(dsl_state) do
|
||||
ok? =
|
||||
dsl_state
|
||||
|> Transformer.get_persisted(:extensions, [])
|
||||
|> Enum.any?(&Spark.implements_behaviour?(&1, AshAuthentication.Provider))
|
||||
|
||||
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."
|
||||
)}
|
||||
end
|
||||
|
||||
defp validate_read_action(dsl_state, action_name) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
:ok <- validate_field_in_values(action, :type, [:read]) do
|
||||
:ok
|
||||
else
|
||||
_ ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions],
|
||||
message: "Expected resource to have either read action named `#{action_name}`"
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
80
lib/ash_authentication/utils.ex
Normal file
80
lib/ash_authentication/utils.ex
Normal file
|
@ -0,0 +1,80 @@
|
|||
defmodule AshAuthentication.Utils do
|
||||
@moduledoc false
|
||||
alias Ash.Resource
|
||||
alias Spark.Dsl.Transformer
|
||||
|
||||
@spec to_sentence(Enum.t(), [
|
||||
{:separator, String.t()} | {:final, String.t()} | {:whitespace, boolean}
|
||||
]) :: String.t()
|
||||
def to_sentence(elements, opts \\ []) do
|
||||
opts =
|
||||
[separator: ",", final: "and", whitespace: true]
|
||||
|> Keyword.merge(opts)
|
||||
|> Map.new()
|
||||
|
||||
if Enum.count(elements) == 1 do
|
||||
elements
|
||||
|> Enum.to_list()
|
||||
|> hd()
|
||||
|> to_string()
|
||||
else
|
||||
elements
|
||||
|> Enum.reverse()
|
||||
|> convert_to_sentence("", opts.separator, opts.final, opts.whitespace)
|
||||
end
|
||||
end
|
||||
|
||||
defp convert_to_sentence([last], result, _, final, true), do: "#{result} #{final} #{last}"
|
||||
defp convert_to_sentence([last], result, _, final, false), do: "#{result}#{final}#{last}"
|
||||
|
||||
defp convert_to_sentence([next | rest], "", sep, final, ws),
|
||||
do: convert_to_sentence(rest, to_string(next), sep, final, ws)
|
||||
|
||||
defp convert_to_sentence([next | rest], result, sep, final, true),
|
||||
do: convert_to_sentence(rest, "#{result}#{sep} #{next}", sep, final, true)
|
||||
|
||||
defp convert_to_sentence([next | rest], result, sep, final, false),
|
||||
do: convert_to_sentence(rest, "#{result}#{sep}#{next}", sep, final, false)
|
||||
|
||||
@doc """
|
||||
Optionally append an element to a collection.
|
||||
|
||||
When `test` is truthy, append `element` to the collection.
|
||||
"""
|
||||
@spec maybe_append(Enum.t(), test :: any, element :: any) :: Enum.t()
|
||||
def maybe_append(collection, test, _element) when test in [nil, false], do: collection
|
||||
def maybe_append(collection, _test, element), do: Enum.concat(collection, [element])
|
||||
|
||||
@doc """
|
||||
Generate the AST for an options function spec.
|
||||
|
||||
Not something you should ever need.
|
||||
"""
|
||||
@spec spec_for_option(keyword) :: Macro.t()
|
||||
def spec_for_option(options) do
|
||||
case Keyword.get(options, :type, :term) do
|
||||
{:behaviour, _module} ->
|
||||
{:module, [], Elixir}
|
||||
|
||||
:string ->
|
||||
{{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}
|
||||
|
||||
terminal ->
|
||||
{terminal, [], Elixir}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Used within transformers to optionally build actions as needed.
|
||||
"""
|
||||
@spec maybe_build_action(map, atom, (map -> map)) :: {:ok, atom | map} | {:error, any}
|
||||
def maybe_build_action(dsl_state, action_name, builder) when is_function(builder, 1) do
|
||||
with nil <- Resource.Info.action(dsl_state, action_name),
|
||||
{:ok, action} <- builder.(dsl_state) do
|
||||
{:ok, Transformer.add_entity(dsl_state, [:actions], action)}
|
||||
else
|
||||
action when is_map(action) -> {:ok, dsl_state}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
109
lib/ash_authentication/validations.ex
Normal file
109
lib/ash_authentication/validations.ex
Normal file
|
@ -0,0 +1,109 @@
|
|||
defmodule AshAuthentication.Validations do
|
||||
@moduledoc """
|
||||
Common validations shared by several transformers.
|
||||
"""
|
||||
|
||||
import AshAuthentication.Utils
|
||||
alias Ash.Resource.Attribute
|
||||
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
|
||||
|
||||
@doc """
|
||||
Given a map validate that the provided field is one of the values provided.
|
||||
"""
|
||||
@spec validate_field_in_values(map, any, [any]) :: :ok | {:error, String.t()}
|
||||
def validate_field_in_values(map, field, []) when is_map(map) when is_map_key(map, field),
|
||||
do: {:error, "Expected `#{inspect(field)}` to not be present."}
|
||||
|
||||
def validate_field_in_values(map, _field, []) when is_map(map), do: :ok
|
||||
|
||||
def validate_field_in_values(map, field, [value])
|
||||
when is_map(map) and is_map_key(map, field) and :erlang.map_get(field, map) == value,
|
||||
do: :ok
|
||||
|
||||
def validate_field_in_values(map, field, [value]) when is_map(map) and is_map_key(map, field),
|
||||
do: {:error, "Expected `#{inspect(field)}` to contain `#{inspect(value)}`"}
|
||||
|
||||
def validate_field_in_values(map, field, values)
|
||||
when is_map(map) and is_list(values) and is_map_key(map, field) do
|
||||
if Map.get(map, field) in values do
|
||||
:ok
|
||||
else
|
||||
values =
|
||||
values
|
||||
|> Enum.map(&"`#{inspect(&1)}`")
|
||||
|> to_sentence(final: "or")
|
||||
|
||||
{:error, "Expected `#{inspect(field)}` to be one of #{values}"}
|
||||
end
|
||||
end
|
||||
|
||||
def validate_field_in_values(map, field, [value]) when is_map(map),
|
||||
do: {:error, "Expected `#{inspect(field)}` to be present and contain `#{inspect(value)}`"}
|
||||
|
||||
def validate_field_in_values(map, field, values) when is_map(map) and is_list(values) do
|
||||
values =
|
||||
values
|
||||
|> Enum.map(&"`#{inspect(&1)}`")
|
||||
|> to_sentence(final: "or")
|
||||
|
||||
{:error, "Expected `#{inspect(field)}` to be present and contain one of #{values}"}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates the uniqueness of all subject names per otp app.
|
||||
"""
|
||||
@spec validate_unique_subject_names(module) :: :ok | no_return
|
||||
def validate_unique_subject_names(otp_app) do
|
||||
otp_app
|
||||
|> AshAuthentication.authenticated_resources()
|
||||
|> Enum.group_by(& &1.subject_name)
|
||||
|> Enum.each(fn
|
||||
{subject_name, configs} when length(configs) > 1 ->
|
||||
resources =
|
||||
configs
|
||||
|> Enum.map(&"`#{inspect(&1.resource)}`")
|
||||
|> AshAuthentication.Utils.to_sentence()
|
||||
|
||||
raise "Error: multiple resources use the `#{subject_name}` subject name: #{resources}"
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find and return a named attribute in the DSL state.
|
||||
"""
|
||||
@spec find_attribute(Dsl.t(), atom) ::
|
||||
{:ok, Attribute.t()} | {:error, Exception.t()}
|
||||
def find_attribute(dsl_state, attribute_name) do
|
||||
dsl_state
|
||||
|> Transformer.get_entities([:attributes])
|
||||
|> Enum.find(&(&1.name == attribute_name))
|
||||
|> case do
|
||||
nil ->
|
||||
resource = Transformer.get_persisted(dsl_state, :module)
|
||||
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:attributes, :attribute],
|
||||
message:
|
||||
"The resource `#{inspect(resource)}` does not define an attribute named `#{inspect(attribute_name)}`"
|
||||
)}
|
||||
|
||||
attribute ->
|
||||
{:ok, attribute}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find and return a persisted option in the DSL state.
|
||||
"""
|
||||
@spec persisted_option(Dsl.t(), atom) :: {:ok, any} | {:error, {:unknown_persisted, atom}}
|
||||
def persisted_option(dsl_state, option) do
|
||||
case Transformer.get_persisted(dsl_state, option) do
|
||||
nil -> {:error, {:unknown_persisted, option}}
|
||||
value -> {:ok, value}
|
||||
end
|
||||
end
|
||||
end
|
161
lib/ash_authentication/validations/action.ex
Normal file
161
lib/ash_authentication/validations/action.ex
Normal file
|
@ -0,0 +1,161 @@
|
|||
defmodule AshAuthentication.Validations.Action do
|
||||
@moduledoc """
|
||||
Validation helpers for Resource actions.
|
||||
"""
|
||||
import AshAuthentication.Utils
|
||||
alias Ash.Resource.{Actions, Info}
|
||||
alias Spark.Error.DslError
|
||||
|
||||
@doc """
|
||||
Validate that a named action actually exists.
|
||||
"""
|
||||
@spec validate_action_exists(map, atom) ::
|
||||
{:ok, Actions.action()} | {:error, Exception.t() | String.t()}
|
||||
def validate_action_exists(dsl_state, action_name) do
|
||||
case Info.action(dsl_state, action_name) do
|
||||
action when is_map(action) ->
|
||||
{:ok, action}
|
||||
|
||||
_ ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions],
|
||||
message: "Expected an action named `#{inspect(action_name)}` to be present"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validate an action's argument has an option set to one of the provided values.
|
||||
"""
|
||||
@spec validate_action_argument_option(Actions.action(), atom, atom, [any]) ::
|
||||
:ok | {:error, Exception.t() | String.t()}
|
||||
def validate_action_argument_option(action, argument_name, field, values) do
|
||||
with argument when is_map(argument) <-
|
||||
Enum.find(action.arguments, :missing_argument, &(&1.name == argument_name)),
|
||||
{:ok, value} <- Map.fetch(argument, field),
|
||||
true <- value in values do
|
||||
:ok
|
||||
else
|
||||
:missing_argument ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :argument],
|
||||
message:
|
||||
"The action `#{inspect(action.name)}` should have an argument named `#{inspect(argument_name)}`"
|
||||
)}
|
||||
|
||||
:error ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :argument],
|
||||
message:
|
||||
"The argument `#{inspect(argument_name)}` on action `#{inspect(action.name)}` is missing the `#{inspect(field)}` property"
|
||||
)}
|
||||
|
||||
false ->
|
||||
case values do
|
||||
[] ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :argument],
|
||||
message:
|
||||
"The argument `#{inspect(argument_name)}` on action `#{inspect(action.name)}` should not have `#{inspect(field)}` set"
|
||||
)}
|
||||
|
||||
[expected] ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :argument],
|
||||
message:
|
||||
"The argument `#{inspect(argument_name)}` on action `#{inspect(action.name)}` should have `#{inspect(field)}` set to `#{inspect(expected)}`"
|
||||
)}
|
||||
|
||||
expected ->
|
||||
expected =
|
||||
expected
|
||||
|> Enum.map(&"`#{inspect(&1)}`")
|
||||
|> to_sentence(final: "or")
|
||||
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :argument],
|
||||
message:
|
||||
"The argument `#{inspect(argument_name)}` on action `#{inspect(action.name)}` should have `#{inspect(field)}` set to one of #{expected}"
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validate the presence of the named change module on an action.
|
||||
"""
|
||||
@spec validate_action_has_change(Actions.action(), module) ::
|
||||
:ok | {:error, Exception.t()}
|
||||
def validate_action_has_change(action, change_module) do
|
||||
has_change? =
|
||||
action
|
||||
|> Map.get(:changes, [])
|
||||
|> Enum.map(&Map.get(&1, :change))
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.any?(&(elem(&1, 0) == change_module))
|
||||
|
||||
if has_change?,
|
||||
do: :ok,
|
||||
else:
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :change],
|
||||
message:
|
||||
"The action `#{inspect(action.name)}` should have the `#{inspect(change_module)}` change present."
|
||||
)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validate the presence of the named validation module on an action.
|
||||
"""
|
||||
@spec validate_action_has_validation(Actions.action(), module) ::
|
||||
:ok | {:error, Exception.t()}
|
||||
def validate_action_has_validation(action, validation_module) do
|
||||
has_validation? =
|
||||
action
|
||||
|> Map.get(:changes, [])
|
||||
|> Enum.map(&Map.get(&1, :validation))
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.any?(&(elem(&1, 0) == validation_module))
|
||||
|
||||
if has_validation?,
|
||||
do: :ok,
|
||||
else:
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :validation],
|
||||
message:
|
||||
"The action `#{inspect(action.name)}` should have the `#{inspect(validation_module)}` validation present."
|
||||
)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validate the presence of the named preparation module on an action.
|
||||
"""
|
||||
@spec validate_action_has_preparation(Actions.action(), module) ::
|
||||
:ok | {:error, Exception.t()}
|
||||
def validate_action_has_preparation(action, preparation_module) do
|
||||
has_preparation? =
|
||||
action
|
||||
|> Map.get(:preparations, [])
|
||||
|> Enum.map(&Map.get(&1, :preparation))
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.any?(&(elem(&1, 0) == preparation_module))
|
||||
|
||||
if has_preparation?,
|
||||
do: :ok,
|
||||
else:
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :preparation],
|
||||
message:
|
||||
"The action `#{inspect(action.name)}` should have the `#{inspect(preparation_module)}` preparation present."
|
||||
)}
|
||||
end
|
||||
end
|
79
lib/ash_authentication/validations/attribute.ex
Normal file
79
lib/ash_authentication/validations/attribute.ex
Normal file
|
@ -0,0 +1,79 @@
|
|||
defmodule AshAuthentication.Validations.Attribute do
|
||||
@moduledoc """
|
||||
Validation helpers for Resource attributes.
|
||||
"""
|
||||
alias Ash.Resource.Info
|
||||
alias Spark.Error.DslError
|
||||
import AshAuthentication.Utils
|
||||
|
||||
@doc """
|
||||
Validate that an option is set correctly on an attribute
|
||||
"""
|
||||
@spec validate_attribute_option(Ash.Resource.Attribute.t(), module, atom, [any]) ::
|
||||
:ok | {:error, Exception.t()}
|
||||
def validate_attribute_option(attribute, resource, field, values) do
|
||||
with {:ok, value} <- Map.fetch(attribute, field),
|
||||
true <- value in values do
|
||||
:ok
|
||||
else
|
||||
:error ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :attribute],
|
||||
message:
|
||||
"The attribute `#{inspect(attribute.name)}` on the `#{inspect(resource)}` resource is missing the `#{inspect(field)}` property"
|
||||
)}
|
||||
|
||||
false ->
|
||||
case values do
|
||||
[] ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :attribute],
|
||||
message:
|
||||
"The attribute `#{inspect(attribute.name)}` on the `#{inspect(resource)}` resource is should not have `#{inspect(field)}` set"
|
||||
)}
|
||||
|
||||
[expected] ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :attribute],
|
||||
message:
|
||||
"The attribute `#{inspect(attribute.name)}` on the `#{inspect(resource)}` resource should have `#{inspect(field)}` set to `#{inspect(expected)}`"
|
||||
)}
|
||||
|
||||
expected ->
|
||||
expected = expected |> Enum.map(&"`#{inspect(&1)}`") |> to_sentence(final: "or")
|
||||
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:actions, :attribute],
|
||||
message:
|
||||
"The attribute `#{inspect(attribute.name)}` on the `#{inspect(resource)}` resource should have `#{inspect(field)}` set to one of #{expected}"
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validate than an attribute has a unique identity applied.
|
||||
"""
|
||||
@spec validate_attribute_unique_constraint(map, atom, module) :: :ok | {:error, Exception.t()}
|
||||
def validate_attribute_unique_constraint(dsl_state, field, resource) do
|
||||
dsl_state
|
||||
|> Info.identities()
|
||||
|> Enum.find(&(&1.keys == [field]))
|
||||
|> case do
|
||||
nil ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:identities, :identity],
|
||||
message:
|
||||
"The `#{inspect(field)}` attribute on the resource `#{inspect(resource)}` should be uniquely constrained"
|
||||
)}
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
32
mix.exs
32
mix.exs
|
@ -14,6 +14,7 @@ defmodule AshAuthentication.MixProject do
|
|||
aliases: aliases(),
|
||||
deps: deps(),
|
||||
package: package(),
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
dialyzer: [
|
||||
plt_add_apps: [:mix, :ex_unit],
|
||||
plt_core_path: "priv/plts",
|
||||
|
@ -38,19 +39,33 @@ defmodule AshAuthentication.MixProject do
|
|||
# Run "mix help compile.app" to learn about applications.
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger],
|
||||
extra_applications: extra_applications(Mix.env()),
|
||||
mod: {AshAuthentication.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
defp extra_applications(:dev), do: [:logger, :bcrypt_elixir]
|
||||
defp extra_applications(:test), do: [:logger, :bcrypt_elixir]
|
||||
defp extra_applications(_), do: [:logger]
|
||||
|
||||
# Run "mix help deps" to learn about dependencies.
|
||||
defp deps do
|
||||
[
|
||||
{:ash, "~> 2.2"},
|
||||
{:bcrypt_elixir, "~> 3.0", optional: true},
|
||||
{:jason, "~> 1.4"},
|
||||
{:joken, "~> 2.5"},
|
||||
{:plug, "~> 1.13"},
|
||||
{:ash_postgres, "~> 1.1", only: [:dev, :test]},
|
||||
{:credo, "~> 1.6", only: [:dev, :test], runtime: false},
|
||||
{:git_ops, "~> 2.4", only: [:dev, :test], runtime: false},
|
||||
{:ex_doc, ">= 0.0.0", only: [:dev, :test]},
|
||||
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
|
||||
{:doctor, "~> 0.18", only: [:dev, :test]},
|
||||
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false}
|
||||
{:elixir_sense, github: "elixir-lsp/elixir_sense", only: [:dev, :test]},
|
||||
{:ex_doc, ">= 0.0.0", only: [:dev, :test]},
|
||||
{:faker, "~> 0.17.0", only: [:dev, :test]},
|
||||
{:git_ops, "~> 2.4", only: [:dev, :test], runtime: false},
|
||||
{:plug_cowboy, "~> 2.5", only: [:dev, :test]},
|
||||
{:mimic, "~> 1.7", only: [:dev, :test]}
|
||||
]
|
||||
end
|
||||
|
||||
|
@ -58,12 +73,17 @@ defmodule AshAuthentication.MixProject do
|
|||
[
|
||||
ci: [
|
||||
"format --check-formatted",
|
||||
"doctor --full",
|
||||
"doctor --full --raise",
|
||||
"credo --strict",
|
||||
"dialyzer",
|
||||
"hex.audit",
|
||||
"test"
|
||||
]
|
||||
],
|
||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]
|
||||
]
|
||||
end
|
||||
|
||||
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||
defp elixirc_paths(:dev), do: ["lib", "test/support", "dev"]
|
||||
defp elixirc_paths(_), do: ["lib"]
|
||||
end
|
||||
|
|
37
mix.lock
37
mix.lock
|
@ -1,18 +1,53 @@
|
|||
%{
|
||||
"ash": {:hex, :ash, "2.2.0", "4fdc0fef5afb3f5045b1ca4e1ccb139b9f703cbc7c21dc645e32ac9582b11f63", [: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.1 and >= 0.1.28", [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", "48eca587e7076fe4f8547e919c0712f081ce85e66c316f6f51dd2535ad046013"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "1.1.0", "d911c72f5a9c8fa524745c0a075d6e7b9a177dddf17e93644772dec73f91c10f", [: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", "11d9c8a968611d1130498a1349615fa29e822373051f218c824e2431677dc4e7"},
|
||||
"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"},
|
||||
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
|
||||
"comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"},
|
||||
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
|
||||
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
|
||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
|
||||
"credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"},
|
||||
"db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"},
|
||||
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
|
||||
"dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"},
|
||||
"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.28", "0bf6546eb7cd6185ae086cbc5d20cd6dbb4b428aad14c02c49f7b554484b4586", [:mix], [], "hexpm", "501cef12286a3231dc80c81352a9453decf9586977f917a96e619293132743fb"},
|
||||
"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", "de434402bd0d266cbe535dbca8befd88ded66996", []},
|
||||
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
|
||||
"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"},
|
||||
"ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.28.6", "2bbd7a143d3014fc26de9056793e97600ae8978af2ced82c2575f130b7c0d7d7", [: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", "bca1441614654710ba37a0e173079273d619f9160cbcc8cd04e6bd59f1ad0e29"},
|
||||
"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"},
|
||||
"git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},
|
||||
"git_ops": {:hex, :git_ops, "2.5.1", "94ab6e3bc69fe765a62cbdb09969016613a154dec8fc4f6ebae682f030451da9", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "1219edc8810dcea40472ec5b7ed04786a9e1b0e4e49d8642b0e1cdfb8a6ad261"},
|
||||
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
|
||||
"joken": {:hex, :joken, "2.5.0", "09be497d804b8115eb6f07615cef2e60c2a1008fb89dc0aef0d4c4b4609b99aa", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "22b25c89617c5ed8ca7b31026340a25ea0f9ca7160f9706b79be9ed81fdf74e7"},
|
||||
"jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"},
|
||||
"makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
|
||||
"makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"},
|
||||
"makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
|
||||
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
|
||||
"mimic": {:hex, :mimic, "1.7.4", "cd2772ffbc9edefe964bc668bfd4059487fa639a5b7f1cbdf4fd22946505aa4f", [:mix], [], "hexpm", "437c61041ecf8a7fae35763ce89859e4973bb0666e6ce76d75efc789204447c3"},
|
||||
"nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
|
||||
"picosat_elixir": {:hex, :picosat_elixir, "0.2.2", "1cacfdb4fb0c3ead5e5e9b1e98ac822a777f07eab35e29c3f8fc7086de2bfb36", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9d0cc569552cca417abea8270a54b71153a63be4b951ff249e94642f1c0f35d1"},
|
||||
"plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"},
|
||||
"plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
|
||||
"postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"},
|
||||
"providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"},
|
||||
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||
"sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"},
|
||||
"spark": {:hex, :spark, "0.1.28", "8ce732daa56ad0dc11190b28461f85e71b67c5b61ce4818841bc8fcdbf799676", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "11b2d52b473345e2ecb4fe70c76ca8400b2fa9417acb629a6bd92db9d3ff953b"},
|
||||
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
|
||||
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
|
||||
"typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},
|
||||
}
|
||||
|
|
21
priv/repo/migrations/20221002235524_install_2_extensions.exs
Normal file
21
priv/repo/migrations/20221002235524_install_2_extensions.exs
Normal file
|
@ -0,0 +1,21 @@
|
|||
defmodule Example.Repo.Migrations.Install2Extensions do
|
||||
@moduledoc """
|
||||
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
execute("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")
|
||||
execute("CREATE EXTENSION IF NOT EXISTS \"citext\"")
|
||||
end
|
||||
|
||||
def down do
|
||||
# Uncomment this if you actually want to uninstall the extensions
|
||||
# when this migration is rolled back:
|
||||
# execute("DROP EXTENSION IF EXISTS \"uuid-ossp\"")
|
||||
# execute("DROP EXTENSION IF EXISTS \"citext\"")
|
||||
end
|
||||
end
|
23
priv/repo/migrations/20221002235526_migrate_resources1.exs
Normal file
23
priv/repo/migrations/20221002235526_migrate_resources1.exs
Normal file
|
@ -0,0 +1,23 @@
|
|||
defmodule Example.Repo.Migrations.MigrateResources1 do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create table(:user_with_username, primary_key: false) do
|
||||
add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
|
||||
add :username, :citext, null: false
|
||||
add :hashed_password, :text, null: false
|
||||
add :created_at, :utc_datetime_usec, null: false, default: fragment("now()")
|
||||
add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
drop table(:user_with_username)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,28 @@
|
|||
defmodule Example.Repo.Migrations.AddTokenRevocationTable do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create unique_index(:user_with_username, [:username],
|
||||
name: "user_with_username_username_index"
|
||||
)
|
||||
|
||||
create table(:token_revocations, primary_key: false) do
|
||||
add :expires_at, :utc_datetime, null: false
|
||||
add :jti, :text, null: false, primary_key: true
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
drop table(:token_revocations)
|
||||
|
||||
drop_if_exists unique_index(:user_with_username, [:username],
|
||||
name: "user_with_username_username_index"
|
||||
)
|
||||
end
|
||||
end
|
6
priv/resource_snapshots/extensions.json
Normal file
6
priv/resource_snapshots/extensions.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"installed": [
|
||||
"uuid-ossp",
|
||||
"citext"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "expires_at",
|
||||
"type": "utc_datetime"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "jti",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "98092CB4D3ED441847CB38BC76408D77082FA08266FC8224FDF994A08A42CECB",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Example.Repo",
|
||||
"schema": null,
|
||||
"table": "token_revocations"
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"uuid_generate_v4()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "username",
|
||||
"type": "citext"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "hashed_password",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"now()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "created_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"now()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": false,
|
||||
"hash": "F14A731ABBE8055A62D20F13B89906CC1EA14CB22BE344DC4E542A17429D55C0",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Example.Repo",
|
||||
"schema": null,
|
||||
"table": "user_with_username"
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"uuid_generate_v4()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "username",
|
||||
"type": "citext"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "hashed_password",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"now()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "created_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"now()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "EFE086DA7BA408EF8829E710CD8F250BE7CDFB956F0AC86EF83726A54210E933",
|
||||
"identities": [
|
||||
{
|
||||
"base_filter": null,
|
||||
"index_name": "user_with_username_username_index",
|
||||
"keys": [
|
||||
"username"
|
||||
],
|
||||
"name": "username"
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Example.Repo",
|
||||
"schema": null,
|
||||
"table": "user_with_username"
|
||||
}
|
73
test/ash_authentication/jwt/config_test.exs
Normal file
73
test/ash_authentication/jwt/config_test.exs
Normal file
|
@ -0,0 +1,73 @@
|
|||
defmodule AshAuthentication.Jwt.ConfigTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
use Mimic
|
||||
alias AshAuthentication.{Jwt.Config, TokenRevocation}
|
||||
|
||||
describe "default_claims/1" do
|
||||
test "it is a token config" do
|
||||
claims = Config.default_claims(Example.UserWithUsername)
|
||||
assert is_map(claims)
|
||||
|
||||
assert Enum.all?(claims, fn {name, config} ->
|
||||
assert is_binary(name)
|
||||
assert is_struct(config, Joken.Claim)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_issuer/1" do
|
||||
test "it correctly generates" do
|
||||
assert "AshAuthentication v1.2.3" = Config.generate_issuer(Version.parse!("1.2.3"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_issuer/3" do
|
||||
test "is true when the issuer starts with \"AshAuthentication\"" do
|
||||
assert Config.validate_issuer("AshAuthentication foo", nil, nil)
|
||||
end
|
||||
|
||||
test "is false otherwise" do
|
||||
garbage = 2 |> Faker.Lorem.words() |> Enum.join(" ")
|
||||
refute Config.validate_issuer(garbage, nil, nil)
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_audience/1" do
|
||||
test "it correctly generates" do
|
||||
assert "~> 1.2" = Config.generate_audience(Version.parse!("1.2.3"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_audience/4" do
|
||||
test "is true when the decoding version meets the minimum requirement" do
|
||||
assert Config.validate_audience("~> 1.2", nil, nil, Version.parse!("1.2.3"))
|
||||
end
|
||||
|
||||
test "is false otherwise" do
|
||||
refute Config.validate_audience("~> 1.2", nil, nil, Version.parse!("1.1.2"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_jti/3" do
|
||||
test "is true when the token has not been revoked" do
|
||||
TokenRevocation
|
||||
|> stub(:revoked?, fn _, _ -> false end)
|
||||
|
||||
assert Config.validate_jti("fake jti", nil, %{resource: Example.UserWithUsername})
|
||||
end
|
||||
|
||||
test "is false when the token has been revoked" do
|
||||
TokenRevocation
|
||||
|> stub(:revoked?, fn _, _ -> true end)
|
||||
|
||||
assert Config.validate_jti("fake jti", nil, %{resource: Example.UserWithUsername})
|
||||
end
|
||||
end
|
||||
|
||||
describe "token_signer/1" do
|
||||
test "it returns a signer configuration" do
|
||||
assert %Joken.Signer{} = Config.token_signer(Example.UserWithUsername)
|
||||
end
|
||||
end
|
||||
end
|
77
test/ash_authentication/jwt_test.exs
Normal file
77
test/ash_authentication/jwt_test.exs
Normal file
|
@ -0,0 +1,77 @@
|
|||
defmodule AshAuthentication.JwtTest do
|
||||
@moduledoc false
|
||||
use AshAuthentication.DataCase, async: true
|
||||
alias AshAuthentication.Jwt
|
||||
|
||||
describe "default_algorithm/0" do
|
||||
test "is a supported algorithm" do
|
||||
assert Jwt.default_algorithm() in Jwt.supported_algorithms()
|
||||
end
|
||||
end
|
||||
|
||||
describe "supported_algorithms/0" do
|
||||
test "is a list of algorithms" do
|
||||
algorithms = Jwt.supported_algorithms()
|
||||
|
||||
assert Enum.any?(algorithms)
|
||||
|
||||
for algorithm <- algorithms do
|
||||
assert is_binary(algorithm)
|
||||
assert byte_size(algorithm) > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "default_lifetime_hrs/0" do
|
||||
test "is a positive integer" do
|
||||
assert Jwt.default_lifetime_hrs() > 0
|
||||
assert is_integer(Jwt.default_lifetime_hrs())
|
||||
end
|
||||
end
|
||||
|
||||
describe "token_for_record/1" do
|
||||
test "correctly generates and signs tokens" do
|
||||
user = build_user()
|
||||
assert {:ok, token, claims} = Jwt.token_for_record(user)
|
||||
|
||||
now = DateTime.utc_now() |> DateTime.to_unix()
|
||||
|
||||
assert token =~ ~r/^[\w-]+\.[\w-]+\.[\w-]+$/
|
||||
assert {:ok, _} = Version.parse_requirement(claims["aud"])
|
||||
assert claims["exp"] > now
|
||||
assert_in_delta(claims["iat"], now, 1.5)
|
||||
assert claims["iss"] =~ ~r/^AshAuthentication v\d\.\d\.\d$/
|
||||
assert claims["jti"] =~ ~r/^[0-9a-z]+$/
|
||||
assert_in_delta(claims["nbf"], now, 1.5)
|
||||
assert claims["sub"] == "user_with_username?id=#{user.id}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "verify/2" do
|
||||
test "it is successful when given a valid token and the correct otp app" do
|
||||
{:ok, token, actual_claims} = build_user() |> Jwt.token_for_record()
|
||||
|
||||
assert {:ok, validated_claims, config} = Jwt.verify(token, :ash_authentication)
|
||||
assert validated_claims == actual_claims
|
||||
assert config.resource == Example.UserWithUsername
|
||||
end
|
||||
|
||||
test "it is unsuccessful when the token signature isn't correct" do
|
||||
{:ok, token, _} = build_user() |> Jwt.token_for_record()
|
||||
|
||||
# mangle the token.
|
||||
[header, payload, signature] = String.split(token, ".")
|
||||
token = [header, payload, String.reverse(signature)] |> Enum.join(".")
|
||||
|
||||
assert :error = Jwt.verify(token, :ash_authentication)
|
||||
end
|
||||
|
||||
test "it is unsuccessful when the token has been revoked" do
|
||||
{:ok, token, _} = build_user() |> Jwt.token_for_record()
|
||||
|
||||
AshAuthentication.TokenRevocation.revoke(Example.TokenRevocation, token)
|
||||
|
||||
assert :error = Jwt.verify(token, :ash_authentication)
|
||||
end
|
||||
end
|
||||
end
|
166
test/ash_authentication/password_authentication/action_test.exs
Normal file
166
test/ash_authentication/password_authentication/action_test.exs
Normal file
|
@ -0,0 +1,166 @@
|
|||
defmodule AshAuthentication.PasswordAuthentication.ActionTest do
|
||||
@moduledoc false
|
||||
use AshAuthentication.DataCase, async: true
|
||||
alias Ash.{Changeset, Query}
|
||||
alias AshAuthentication.PasswordAuthentication.Info
|
||||
|
||||
describe "register action" do
|
||||
@describetag resource: Example.UserWithUsername
|
||||
setup :resource_config
|
||||
|
||||
test "password confirmation is verified", %{config: config, resource: resource} do
|
||||
assert {:error, error} =
|
||||
resource
|
||||
|> Changeset.for_create(:register, %{
|
||||
config.identity_field => username(),
|
||||
config.password_field => password(),
|
||||
config.password_confirmation_field => password()
|
||||
})
|
||||
|> Example.create()
|
||||
|
||||
assert Exception.message(error) =~ "#{config.password_confirmation_field}: does not match"
|
||||
end
|
||||
|
||||
test "users can be created", %{config: config, resource: resource} do
|
||||
password = password()
|
||||
|
||||
attrs = %{
|
||||
config.identity_field => username(),
|
||||
config.password_field => password,
|
||||
config.password_confirmation_field => password
|
||||
}
|
||||
|
||||
assert {:ok, user} =
|
||||
resource
|
||||
|> Changeset.for_create(:register, attrs)
|
||||
|> Example.create()
|
||||
|
||||
refute is_nil(user.id)
|
||||
|
||||
created_username = user |> Map.fetch!(config.identity_field) |> to_string()
|
||||
|
||||
assert created_username == Map.get(attrs, config.identity_field)
|
||||
end
|
||||
|
||||
test "the password is hashed correctly", %{config: config, resource: resource} do
|
||||
password = password()
|
||||
|
||||
assert user =
|
||||
resource
|
||||
|> Changeset.for_create(:register, %{
|
||||
config.identity_field => username(),
|
||||
config.password_field => password,
|
||||
config.password_confirmation_field => password
|
||||
})
|
||||
|> Example.create!()
|
||||
|
||||
assert {:ok, hashed} = Map.fetch(user, config.hashed_password_field)
|
||||
assert hashed != password
|
||||
|
||||
assert config.hash_provider.valid?(password, hashed)
|
||||
end
|
||||
end
|
||||
|
||||
describe "sign_in action" do
|
||||
@describetag resource: Example.UserWithUsername
|
||||
setup :resource_config
|
||||
|
||||
test "when the user doesn't exist, it returns an empty result", %{
|
||||
config: config,
|
||||
resource: resource
|
||||
} do
|
||||
assert {:error, _} =
|
||||
resource
|
||||
|> Query.for_read(:sign_in, %{
|
||||
config.identity_field => username(),
|
||||
config.password_field => password()
|
||||
})
|
||||
|> Example.read()
|
||||
end
|
||||
|
||||
test "when the user exists, but the password is incorrect, it returns an empty result", %{
|
||||
config: config,
|
||||
resource: resource
|
||||
} do
|
||||
username = username()
|
||||
password = password()
|
||||
|
||||
resource
|
||||
|> Changeset.for_create(:register, %{
|
||||
config.identity_field => username,
|
||||
config.password_field => password,
|
||||
config.password_confirmation_field => password
|
||||
})
|
||||
|> Example.create!()
|
||||
|
||||
assert {:error, _} =
|
||||
resource
|
||||
|> Query.for_read(:sign_in, %{
|
||||
config.identity_field => username,
|
||||
config.password_field => password()
|
||||
})
|
||||
|> Example.read()
|
||||
end
|
||||
|
||||
test "when the user exists, and the password is correct, it returns the user", %{
|
||||
config: config,
|
||||
resource: resource
|
||||
} do
|
||||
username = username()
|
||||
password = password()
|
||||
|
||||
expected =
|
||||
resource
|
||||
|> Changeset.for_create(:register, %{
|
||||
config.identity_field => username,
|
||||
config.password_field => password,
|
||||
config.password_confirmation_field => password
|
||||
})
|
||||
|> Example.create!()
|
||||
|
||||
assert {:ok, [actual]} =
|
||||
resource
|
||||
|> Query.for_read(:sign_in, %{
|
||||
config.identity_field => username,
|
||||
config.password_field => password
|
||||
})
|
||||
|> Example.read()
|
||||
|
||||
assert actual.id == expected.id
|
||||
end
|
||||
|
||||
test "when the user exists, and the password is correct it generates a token", %{
|
||||
config: config,
|
||||
resource: resource
|
||||
} do
|
||||
username = username()
|
||||
password = password()
|
||||
|
||||
resource
|
||||
|> Changeset.for_create(:register, %{
|
||||
config.identity_field => username,
|
||||
config.password_field => password,
|
||||
config.password_confirmation_field => password
|
||||
})
|
||||
|> Example.create!()
|
||||
|
||||
assert {:ok, [user]} =
|
||||
resource
|
||||
|> Query.for_read(:sign_in, %{
|
||||
config.identity_field => username,
|
||||
config.password_field => password
|
||||
})
|
||||
|> Example.read()
|
||||
|
||||
assert is_binary(user.__metadata__.token)
|
||||
end
|
||||
end
|
||||
|
||||
defp resource_config(%{resource: resource}) do
|
||||
config =
|
||||
resource
|
||||
|> Info.options()
|
||||
|
||||
{:ok, config: config}
|
||||
end
|
||||
end
|
|
@ -0,0 +1,49 @@
|
|||
defmodule AshAuthentication.IdentityTest do
|
||||
@moduledoc false
|
||||
use AshAuthentication.DataCase, async: true
|
||||
alias Ash.Error
|
||||
alias AshAuthentication.{PasswordAuthentication, PasswordAuthentication.Info}
|
||||
|
||||
describe "sign_in_action/2" do
|
||||
@describetag resource: Example.UserWithUsername
|
||||
setup :resource_config
|
||||
|
||||
test "when provided invalid credentials", %{resource: resource, config: config} do
|
||||
assert {:error, error} =
|
||||
PasswordAuthentication.sign_in_action(resource, %{
|
||||
config.identity_field => username(),
|
||||
config.password_field => password()
|
||||
})
|
||||
|
||||
assert Error.error_messages(error.errors, "", false) =~ "Authentication failed"
|
||||
end
|
||||
|
||||
test "when provided valid credentials", %{resource: resource, config: config} do
|
||||
username = username()
|
||||
password = password()
|
||||
|
||||
{:ok, expected} =
|
||||
PasswordAuthentication.register_action(resource, %{
|
||||
config.identity_field => username,
|
||||
config.password_field => password,
|
||||
config.password_confirmation_field => password
|
||||
})
|
||||
|
||||
assert {:ok, actual} =
|
||||
PasswordAuthentication.sign_in_action(resource, %{
|
||||
config.identity_field => username,
|
||||
config.password_field => password
|
||||
})
|
||||
|
||||
assert actual.id == expected.id
|
||||
end
|
||||
end
|
||||
|
||||
defp resource_config(%{resource: resource}) do
|
||||
config =
|
||||
resource
|
||||
|> Info.options()
|
||||
|
||||
{:ok, config: config}
|
||||
end
|
||||
end
|
24
test/ash_authentication/token_revocation_test.exs
Normal file
24
test/ash_authentication/token_revocation_test.exs
Normal file
|
@ -0,0 +1,24 @@
|
|||
defmodule AshAuthentication.TokenRevocationTest do
|
||||
@moduledoc false
|
||||
use AshAuthentication.DataCase, async: true
|
||||
alias AshAuthentication.{Jwt, TokenRevocation}
|
||||
|
||||
describe "revoke/2" do
|
||||
test "it revokes tokens" do
|
||||
{token, %{"jti" => jti}} = build_token()
|
||||
refute TokenRevocation.revoked?(Example.TokenRevocation, jti)
|
||||
|
||||
assert :ok = TokenRevocation.revoke(Example.TokenRevocation, token)
|
||||
|
||||
assert TokenRevocation.revoked?(Example.TokenRevocation, jti)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_token do
|
||||
{:ok, token, claims} =
|
||||
build_user()
|
||||
|> Jwt.token_for_record()
|
||||
|
||||
{token, claims}
|
||||
end
|
||||
end
|
|
@ -1,8 +1,18 @@
|
|||
defmodule AshAuthenticationTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case
|
||||
doctest AshAuthentication
|
||||
|
||||
test "greets the world" do
|
||||
assert AshAuthentication.hello() == :world
|
||||
describe "authenticated_resources/0" do
|
||||
test "it correctly locates all authenticatable resources" do
|
||||
assert [
|
||||
%{
|
||||
api: Example,
|
||||
providers: [AshAuthentication.PasswordAuthentication],
|
||||
resource: Example.UserWithUsername,
|
||||
subject_name: :user_with_username
|
||||
}
|
||||
] = AshAuthentication.authenticated_resources(:ash_authentication)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
76
test/support/data_case.ex
Normal file
76
test/support/data_case.ex
Normal file
|
@ -0,0 +1,76 @@
|
|||
defmodule AshAuthentication.DataCase do
|
||||
@moduledoc """
|
||||
This module defines the setup for tests requiring
|
||||
access to the application's data layer.
|
||||
|
||||
You may define functions here to be used as helpers in
|
||||
your tests.
|
||||
|
||||
Finally, if the test case interacts with the database,
|
||||
we enable the SQL sandbox, so changes done to the database
|
||||
are reverted at the end of every test. If you are using
|
||||
PostgreSQL, you can even run database tests asynchronously
|
||||
by setting `use AshAuthentication.DataCase, async: true`, although
|
||||
this option is not recommended for other databases.
|
||||
"""
|
||||
|
||||
use ExUnit.CaseTemplate
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
|
||||
using do
|
||||
quote do
|
||||
alias Example.Repo
|
||||
|
||||
import Ecto
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
import AshAuthentication.DataCase
|
||||
end
|
||||
end
|
||||
|
||||
setup tags do
|
||||
AshAuthentication.DataCase.setup_sandbox(tags)
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets up the sandbox based on the test tags.
|
||||
"""
|
||||
def setup_sandbox(tags) do
|
||||
pid = Sandbox.start_owner!(Example.Repo, shared: not tags[:async])
|
||||
on_exit(fn -> Sandbox.stop_owner(pid) end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
A helper that transforms changeset errors into a map of messages.
|
||||
|
||||
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
|
||||
assert "password is too short" in errors_on(changeset).password
|
||||
assert %{password: ["password is too short"]} = errors_on(changeset)
|
||||
|
||||
"""
|
||||
def errors_on(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
||||
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
|
||||
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
def username, do: Faker.Internet.user_name()
|
||||
def password, do: Faker.Lorem.words(4) |> Enum.join(" ")
|
||||
|
||||
def build_user(attrs \\ %{}) do
|
||||
password = password()
|
||||
|
||||
attrs =
|
||||
attrs
|
||||
|> Map.put_new(:username, username())
|
||||
|> Map.put_new(:password, password)
|
||||
|> Map.put_new(:password_confirmation, password)
|
||||
|
||||
Example.UserWithUsername
|
||||
|> Ash.Changeset.for_create(:register, attrs)
|
||||
|> Example.create!()
|
||||
end
|
||||
end
|
8
test/support/example.ex
Normal file
8
test/support/example.ex
Normal file
|
@ -0,0 +1,8 @@
|
|||
defmodule Example do
|
||||
@moduledoc false
|
||||
use Ash.Api, otp_app: :ash_authentication
|
||||
|
||||
resources do
|
||||
registry Example.Registry
|
||||
end
|
||||
end
|
21
test/support/example/auth_plug.ex
Normal file
21
test/support/example/auth_plug.ex
Normal file
|
@ -0,0 +1,21 @@
|
|||
defmodule Example.AuthPlug do
|
||||
@moduledoc false
|
||||
use AshAuthentication.Plug, otp_app: :ash_authentication
|
||||
|
||||
@impl true
|
||||
def handle_success(conn, actor, token) do
|
||||
conn
|
||||
|> store_in_session(actor)
|
||||
|> send_resp(200, """
|
||||
Token: #{token}
|
||||
|
||||
Actor: #{inspect(actor)}
|
||||
""")
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_failure(conn, _) do
|
||||
conn
|
||||
|> send_resp(401, "Sorry mate")
|
||||
end
|
||||
end
|
9
test/support/example/registry.ex
Normal file
9
test/support/example/registry.ex
Normal file
|
@ -0,0 +1,9 @@
|
|||
defmodule Example.Registry do
|
||||
@moduledoc false
|
||||
use Ash.Registry, extensions: [Ash.Registry.ResourceValidations]
|
||||
|
||||
entries do
|
||||
entry Example.UserWithUsername
|
||||
entry Example.TokenRevocation
|
||||
end
|
||||
end
|
7
test/support/example/repo.ex
Normal file
7
test/support/example/repo.ex
Normal file
|
@ -0,0 +1,7 @@
|
|||
defmodule Example.Repo do
|
||||
@moduledoc false
|
||||
use AshPostgres.Repo, otp_app: :ash_authentication
|
||||
|
||||
@doc false
|
||||
def installed_extensions, do: ["uuid-ossp", "citext"]
|
||||
end
|
24
test/support/example/token_revocation.ex
Normal file
24
test/support/example/token_revocation.ex
Normal file
|
@ -0,0 +1,24 @@
|
|||
defmodule Example.TokenRevocation do
|
||||
@moduledoc false
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication.TokenRevocation]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
jti: String.t(),
|
||||
expires_at: DateTime.t()
|
||||
}
|
||||
|
||||
actions do
|
||||
destroy :expire
|
||||
end
|
||||
|
||||
postgres do
|
||||
table("token_revocations")
|
||||
repo(Example.Repo)
|
||||
end
|
||||
|
||||
revocation do
|
||||
api Example
|
||||
end
|
||||
end
|
57
test/support/example/user_with_username.ex
Normal file
57
test/support/example/user_with_username.ex
Normal file
|
@ -0,0 +1,57 @@
|
|||
defmodule Example.UserWithUsername do
|
||||
@moduledoc false
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: Ecto.UUID.t(),
|
||||
username: String.t(),
|
||||
hashed_password: String.t(),
|
||||
created_at: DateTime.t(),
|
||||
updated_at: DateTime.t()
|
||||
}
|
||||
|
||||
attributes do
|
||||
uuid_primary_key(:id)
|
||||
|
||||
attribute(:username, :ci_string, allow_nil?: false)
|
||||
attribute(:hashed_password, :string, allow_nil?: false, sensitive?: true)
|
||||
|
||||
create_timestamp(:created_at)
|
||||
update_timestamp(:updated_at)
|
||||
end
|
||||
|
||||
actions do
|
||||
destroy :destroy do
|
||||
primary? true
|
||||
end
|
||||
end
|
||||
|
||||
code_interface do
|
||||
define_for(Example)
|
||||
end
|
||||
|
||||
postgres do
|
||||
table("user_with_username")
|
||||
repo(Example.Repo)
|
||||
end
|
||||
|
||||
authentication do
|
||||
api(Example)
|
||||
end
|
||||
|
||||
password_authentication do
|
||||
identity_field(:username)
|
||||
hashed_password_field(:hashed_password)
|
||||
end
|
||||
|
||||
identities do
|
||||
identity(:username, [:username])
|
||||
end
|
||||
|
||||
tokens do
|
||||
enabled?(true)
|
||||
revocation_resource(Example.TokenRevocation)
|
||||
end
|
||||
end
|
|
@ -1 +1,2 @@
|
|||
ExUnit.start()
|
||||
Mimic.copy(AshAuthentication.TokenRevocation)
|
||||
ExUnit.start(capture_log: true)
|
||||
|
|
Loading…
Reference in a new issue