mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 12:52:55 +12:00
feat(Ash.PlugHelpers): Support standard actor configuration. (#16)
* improvement(docs): change all references to `actor` to `user`. The word "actor" has special meaning in the Ash ecosystem. * chore: format `dev` directory also. * feat(Ash.PlugHelpers): Support standard actor configuration. * Adds the `:set_actor` plug which will set the actor to a resource based on the subject name. * Also includes GraphQL and JSON:API interfaces in the devserver for testing.
This commit is contained in:
parent
a432bd5477
commit
8797005175
34 changed files with 809 additions and 167 deletions
|
@ -1,5 +1,10 @@
|
|||
%Doctor.Config{
|
||||
ignore_modules: [~r/^Inspect\./, ~r/.Plug$/, AshAuthentication.InfoGenerator],
|
||||
ignore_modules: [
|
||||
~r/^Inspect\./,
|
||||
~r/.Plug$/,
|
||||
AshAuthentication.InfoGenerator,
|
||||
AshAuthentication.Plug.Macros
|
||||
],
|
||||
ignore_paths: [],
|
||||
min_module_doc_coverage: 40,
|
||||
min_module_spec_coverage: 0,
|
||||
|
|
|
@ -16,7 +16,7 @@ spark_locals_without_parens = [
|
|||
import_deps: [:ash, :spark],
|
||||
inputs: [
|
||||
"*.{ex,exs}",
|
||||
"{config,lib,test}/**/*.{ex,exs}"
|
||||
"{dev,config,lib,test}/**/*.{ex,exs}"
|
||||
],
|
||||
plugins: [Spark.Formatter],
|
||||
export: [
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
import Config
|
||||
|
||||
config :mime, :types, %{
|
||||
"application/vnd.api+json" => ["json"]
|
||||
}
|
||||
|
||||
import_config "#{config_env()}.exs"
|
||||
|
|
|
@ -17,7 +17,7 @@ defmodule DevServer do
|
|||
|
||||
[
|
||||
{DevServer.Session, []},
|
||||
{Plug.Cowboy, scheme: :http, plug: DevServer.Plug, options: opts}
|
||||
{Plug.Cowboy, scheme: :http, plug: DevServer.Router, options: opts}
|
||||
]
|
||||
|> Supervisor.init(strategy: :one_for_all)
|
||||
end
|
||||
|
|
14
dev/dev_server/api_router.ex
Normal file
14
dev/dev_server/api_router.ex
Normal file
|
@ -0,0 +1,14 @@
|
|||
defmodule DevServer.ApiRouter do
|
||||
@moduledoc """
|
||||
Router for API Requests.
|
||||
"""
|
||||
use Plug.Router
|
||||
import Example.AuthPlug
|
||||
|
||||
plug(:load_from_bearer)
|
||||
plug(:set_actor, :user_with_username)
|
||||
plug(:match)
|
||||
plug(:dispatch)
|
||||
|
||||
forward("/", to: DevServer.JsonApiRouter)
|
||||
end
|
|
@ -1,6 +1,6 @@
|
|||
defmodule DevServer.ClearSession do
|
||||
@moduledoc """
|
||||
Resets the session storage, to 'log out" all actors.
|
||||
Resets the session storage, to 'log out" all users.
|
||||
"""
|
||||
|
||||
@behaviour Plug
|
||||
|
|
26
dev/dev_server/gql_router.ex
Normal file
26
dev/dev_server/gql_router.ex
Normal file
|
@ -0,0 +1,26 @@
|
|||
defmodule DevServer.GqlRouter do
|
||||
@moduledoc """
|
||||
Router for GraphQL requests.
|
||||
"""
|
||||
use Plug.Router
|
||||
import Example.AuthPlug
|
||||
|
||||
plug(:load_from_bearer)
|
||||
plug(:set_actor, :user_with_username)
|
||||
plug(AshGraphql.Plug)
|
||||
plug(:match)
|
||||
plug(:dispatch)
|
||||
|
||||
forward("/playground",
|
||||
to: Absinthe.Plug.GraphiQL,
|
||||
init_opts: [
|
||||
schema: Example.Schema,
|
||||
interface: :playground
|
||||
]
|
||||
)
|
||||
|
||||
forward("/",
|
||||
to: Absinthe.Plug,
|
||||
init_opts: [schema: Example.Schema]
|
||||
)
|
||||
end
|
4
dev/dev_server/json_api_router.ex
Normal file
4
dev/dev_server/json_api_router.ex
Normal file
|
@ -0,0 +1,4 @@
|
|||
defmodule DevServer.JsonApiRouter do
|
||||
@moduledoc false
|
||||
use AshJsonApi.Api.Router, api: Example, registry: Example.Registry
|
||||
end
|
|
@ -1,24 +1,19 @@
|
|||
defmodule DevServer.Plug do
|
||||
defmodule DevServer.Router 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(:fetch_query_params)
|
||||
plug(Plug.Logger)
|
||||
plug(:load_from_session)
|
||||
plug(:match)
|
||||
plug(:dispatch)
|
||||
|
||||
forward("/auth", to: Example.AuthPlug.Router)
|
||||
forward("/auth", to: Example.AuthPlug)
|
||||
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
|
||||
forward("/api", to: DevServer.ApiRouter)
|
||||
forward("/gql", to: DevServer.GqlRouter)
|
||||
forward("/", to: DevServer.WebRouter)
|
||||
end
|
|
@ -22,7 +22,7 @@ defmodule DevServer.TestPage do
|
|||
def call(conn, _opts) do
|
||||
resources = AshAuthentication.authenticated_resources(:ash_authentication)
|
||||
|
||||
current_actors =
|
||||
current_users =
|
||||
conn.assigns
|
||||
|> Stream.filter(fn {key, _value} ->
|
||||
key
|
||||
|
@ -31,7 +31,7 @@ defmodule DevServer.TestPage do
|
|||
end)
|
||||
|> Map.new()
|
||||
|
||||
payload = render(resources: resources, current_actors: current_actors)
|
||||
payload = render(resources: resources, current_users: current_users)
|
||||
Conn.send_resp(conn, 200, payload)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -32,18 +32,18 @@
|
|||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.any?(@current_actors) do %>
|
||||
<h2>Current actors:</h2>
|
||||
<%= if Enum.any?(@current_users) do %>
|
||||
<h2>Current users:</h2>
|
||||
<a href="/clear_session">Clear session</a>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
<%= for {name, actor} <- @current_actors do %>
|
||||
<%= for {name, user} <- @current_users do %>
|
||||
<tr>
|
||||
<td><code><pre>@<%= name %></pre></code></td>
|
||||
<td><code><pre><%= inspect actor, pretty: true %></pre></code></td>
|
||||
<td><code><pre><%= inspect user, pretty: true %></pre></code></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</table>
|
||||
|
|
17
dev/dev_server/web_router.ex
Normal file
17
dev/dev_server/web_router.ex
Normal file
|
@ -0,0 +1,17 @@
|
|||
defmodule DevServer.WebRouter do
|
||||
@moduledoc """
|
||||
Router for web (browser) requests.
|
||||
"""
|
||||
use Plug.Router
|
||||
import Example.AuthPlug
|
||||
|
||||
plug(:load_from_session)
|
||||
plug(:match)
|
||||
plug(:dispatch)
|
||||
|
||||
get("/", to: DevServer.TestPage)
|
||||
|
||||
match _ do
|
||||
send_resp(conn, 404, "NOT FOUND")
|
||||
end
|
||||
end
|
|
@ -235,7 +235,7 @@ defmodule AshAuthentication do
|
|||
|> Query.filter(^primary_key)
|
||||
|> config.api.read()
|
||||
|> case do
|
||||
{:ok, [actor]} -> {:ok, actor}
|
||||
{:ok, [user]} -> {:ok, user}
|
||||
_ -> {:error, "Invalid subject"}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,14 +8,14 @@ defmodule AshAuthentication.PasswordAuthentication do
|
|||
identity_field: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the attribute which uniquely identifies the actor. Usually something like `username` or `email_address`.
|
||||
The name of the attribute which uniquely identifies the user. Usually something like `username` or `email_address`.
|
||||
""",
|
||||
default: :username
|
||||
],
|
||||
hashed_password_field: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the attribute within which to store the actor's password once it has been hashed.
|
||||
The name of the attribute within which to store the user's password once it has been hashed.
|
||||
""",
|
||||
default: :hashed_password
|
||||
],
|
||||
|
@ -115,7 +115,7 @@ defmodule AshAuthentication.PasswordAuthentication do
|
|||
alias Plug.Conn
|
||||
|
||||
@doc """
|
||||
Attempt to sign in an actor of the provided resource type.
|
||||
Attempt to sign in an user of the provided resource type.
|
||||
|
||||
## Example
|
||||
|
||||
|
@ -129,7 +129,7 @@ defmodule AshAuthentication.PasswordAuthentication do
|
|||
as: :sign_in
|
||||
|
||||
@doc """
|
||||
Attempt to register an actor of the provided resource type.
|
||||
Attempt to register an user of the provided resource type.
|
||||
|
||||
## Example
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do
|
|||
alias AshAuthentication.PasswordAuthentication
|
||||
|
||||
@doc """
|
||||
Attempt to sign in an actor of the provided resource type.
|
||||
Attempt to sign in an user of the provided resource type.
|
||||
|
||||
## Example
|
||||
|
||||
|
@ -27,14 +27,14 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do
|
|||
|> Query.for_read(action, attributes)
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, [actor]} -> {:ok, actor}
|
||||
{:ok, [user]} -> {:ok, user}
|
||||
{:ok, []} -> {:error, "Invalid username or password"}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempt to register an actor of the provided resource type.
|
||||
Attempt to register an user of the provided resource type.
|
||||
|
||||
## Example
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do
|
|||
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
|
||||
request/callback process which can be used to register and sign in an user in
|
||||
a single flow. This doesn't really work that well with `PasswordAuthentication` which has
|
||||
seperate "registration" and "sign-in" actions.
|
||||
|
||||
|
@ -37,8 +37,8 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do
|
|||
|> 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})
|
||||
{:ok, user} when is_struct(user, config.resource) ->
|
||||
private_store(conn, {:success, user})
|
||||
|
||||
{:error, changeset} ->
|
||||
private_store(conn, {:failure, changeset})
|
||||
|
|
|
@ -32,7 +32,7 @@ defmodule AshAuthentication.Plug do
|
|||
end
|
||||
```
|
||||
|
||||
In order to load any authenticated actors for either web or API users you can add the following to your router:
|
||||
In order to load any authenticated users for either web or API users you can add the following to your router:
|
||||
|
||||
```elixir
|
||||
import MyAppWeb.AuthPlug
|
||||
|
@ -69,10 +69,10 @@ defmodule AshAuthentication.Plug do
|
|||
do useful things like session and query param fetching.
|
||||
"""
|
||||
|
||||
alias Ash.{Api, Changeset, Resource}
|
||||
alias AshAuthentication.Plug.Helpers
|
||||
alias Ash.{Changeset, Error, Resource}
|
||||
alias AshAuthentication.Plug.{Defaults, Helpers, Macros}
|
||||
alias Plug.Conn
|
||||
alias Spark.Dsl.Extension
|
||||
require Macros
|
||||
|
||||
@type authenticator_config :: %{
|
||||
api: module,
|
||||
|
@ -103,7 +103,7 @@ defmodule AshAuthentication.Plug do
|
|||
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()
|
||||
@callback handle_failure(Conn.t(), nil | Changeset.t() | Error.t()) :: Conn.t()
|
||||
|
||||
defmacro __using__(opts) do
|
||||
otp_app =
|
||||
|
@ -112,58 +112,15 @@ defmodule AshAuthentication.Plug do
|
|||
|> Macro.expand_once(__CALLER__)
|
||||
|
||||
quote do
|
||||
require Ash.Api.Info
|
||||
|
||||
unquote(otp_app)
|
||||
|> Application.compile_env(:ash_apis, [])
|
||||
|> Stream.flat_map(&Api.Info.depend_on_resources(&1))
|
||||
|> Stream.map(&{&1, Extension.get_persisted(&1, :authentication)})
|
||||
|> Stream.reject(&(elem(&1, 1) == nil))
|
||||
|> Stream.map(&{elem(&1, 0), elem(&1, 1).subject_name})
|
||||
|> Enum.group_by(&elem(&1, 1), &elem(&1, 0))
|
||||
|> Enum.reject(&(length(elem(&1, 1)) < 2))
|
||||
|> case do
|
||||
[] ->
|
||||
nil
|
||||
|
||||
duplicates ->
|
||||
import AshAuthentication.Utils, only: [to_sentence: 2]
|
||||
|
||||
duplicates =
|
||||
duplicates
|
||||
|> Enum.map(fn {subject_name, resources} ->
|
||||
resources =
|
||||
resources
|
||||
|> Enum.map(&"`#{inspect(&1)}`")
|
||||
|> to_sentence(final: "and")
|
||||
|
||||
" `#{subject_name}`: #{resources}\n"
|
||||
end)
|
||||
|
||||
raise """
|
||||
Error: There are multiple resources configured with the same subject name.
|
||||
|
||||
This is bad because we will be unable to correctly convert between subjects and resources.
|
||||
|
||||
#{duplicates}
|
||||
"""
|
||||
end
|
||||
require Macros
|
||||
Macros.validate_subject_name_uniqueness(unquote(otp_app))
|
||||
|
||||
@behaviour AshAuthentication.Plug
|
||||
@behaviour 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`.
|
||||
"""
|
||||
@moduledoc false
|
||||
use AshAuthentication.Plug.Router,
|
||||
otp_app: unquote(otp_app),
|
||||
return_to:
|
||||
|
@ -173,58 +130,26 @@ defmodule AshAuthentication.Plug do
|
|||
|> Module.concat()
|
||||
end
|
||||
|
||||
@doc """
|
||||
The default implementation of `handle_success/3`.
|
||||
Macros.define_load_from_session(unquote(otp_app))
|
||||
Macros.define_load_from_bearer(unquote(otp_app))
|
||||
Macros.define_revoke_bearer_tokens(unquote(otp_app))
|
||||
|
||||
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
|
||||
@impl true
|
||||
defdelegate handle_success(conn, user, token), to: Defaults
|
||||
|
||||
@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
|
||||
@impl true
|
||||
defdelegate handle_failure(conn, error), to: Defaults
|
||||
|
||||
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)
|
||||
@impl true
|
||||
defdelegate init(opts), to: Router
|
||||
|
||||
@doc """
|
||||
Attempt to retrieve all actors from the connections' session.
|
||||
@impl true
|
||||
defdelegate call(conn, opts), to: Router
|
||||
|
||||
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))
|
||||
defdelegate set_actor(conn, subject_name), to: Helpers
|
||||
defdelegate store_in_session(conn, user), to: Helpers
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
36
lib/ash_authentication/plug/defaults.ex
Normal file
36
lib/ash_authentication/plug/defaults.ex
Normal file
|
@ -0,0 +1,36 @@
|
|||
defmodule AshAuthentication.Plug.Defaults do
|
||||
@moduledoc """
|
||||
Provides the default implementations of `handle_success/3` and
|
||||
`handle_failure/2` used in generated authentication plugs.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Error, Resource}
|
||||
alias Plug.Conn
|
||||
import AshAuthentication.Plug.Helpers
|
||||
import Plug.Conn
|
||||
|
||||
@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, user, _token) do
|
||||
conn
|
||||
|> store_in_session(user)
|
||||
|> 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() | Error.t()) :: Conn.t()
|
||||
def handle_failure(conn, _) do
|
||||
conn
|
||||
|> send_resp(401, "Access denied")
|
||||
end
|
||||
end
|
|
@ -41,8 +41,8 @@ defmodule AshAuthentication.Plug.Dispatcher do
|
|||
%{state: :sent} ->
|
||||
conn
|
||||
|
||||
%{private: %{authentication_result: {:success, actor}}} ->
|
||||
return_to.handle_success(conn, actor, Map.get(actor.__metadata__, :token))
|
||||
%{private: %{authentication_result: {:success, user}}} ->
|
||||
return_to.handle_success(conn, user, Map.get(user.__metadata__, :token))
|
||||
|
||||
%{private: %{authentication_result: {:failure, reason}}} ->
|
||||
return_to.handle_failure(conn, reason)
|
||||
|
|
|
@ -2,23 +2,24 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
@moduledoc """
|
||||
Authentication helpers for use in your router, etc.
|
||||
"""
|
||||
alias Ash.{Changeset, Error, Resource}
|
||||
|
||||
alias Ash.{Changeset, Error, PlugHelpers, Resource}
|
||||
alias AshAuthentication.{Info, Jwt, TokenRevocation}
|
||||
alias Plug.Conn
|
||||
|
||||
@doc """
|
||||
Store the actor in the connections' session.
|
||||
Store the user 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)
|
||||
def store_in_session(conn, user) do
|
||||
subject_name = AshAuthentication.Info.authentication_subject_name!(user.__struct__)
|
||||
subject = AshAuthentication.resource_to_subject(user)
|
||||
|
||||
Conn.put_session(conn, subject_name, subject)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Given a list of subjects, turn as many as possible into actors.
|
||||
Given a list of subjects, turn as many as possible into users.
|
||||
"""
|
||||
@spec load_subjects([AshAuthentication.subject()], module) :: map
|
||||
def load_subjects(subjects, otp_app) when is_list(subjects) do
|
||||
|
@ -26,15 +27,17 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
otp_app
|
||||
|> AshAuthentication.authenticated_resources()
|
||||
|> Stream.map(&{to_string(&1.subject_name), &1})
|
||||
|> Map.new()
|
||||
|
||||
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
|
||||
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config) do
|
||||
current_subject_name = current_subject_name(config.subject_name)
|
||||
Map.put(result, current_subject_name, actor)
|
||||
|
||||
Map.put(result, current_subject_name, user)
|
||||
else
|
||||
_ -> result
|
||||
end
|
||||
|
@ -42,13 +45,13 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Attempt to retrieve all actors from the connections' session.
|
||||
Attempt to retrieve all users 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
|
||||
retrieves any users 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`.
|
||||
If there is no user 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
|
||||
|
@ -58,8 +61,8 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
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)
|
||||
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config) do
|
||||
Conn.assign(conn, current_subject_name, user)
|
||||
else
|
||||
_ ->
|
||||
Conn.assign(conn, current_subject_name, nil)
|
||||
|
@ -72,7 +75,9 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
|
||||
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.
|
||||
into the assigns under their subject name (with the prefix `current_`).
|
||||
|
||||
If there is no user present for a resource then the assign is set to `nil`.
|
||||
"""
|
||||
@spec retrieve_from_bearer(Conn.t(), module) :: Conn.t()
|
||||
def retrieve_from_bearer(conn, otp_app) do
|
||||
|
@ -82,9 +87,10 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
|> 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),
|
||||
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config),
|
||||
current_subject_name <- current_subject_name(config.subject_name) do
|
||||
Conn.assign(conn, current_subject_name, actor)
|
||||
conn
|
||||
|> Conn.assign(current_subject_name, user)
|
||||
else
|
||||
_ -> conn
|
||||
end
|
||||
|
@ -113,10 +119,47 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
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 """
|
||||
Set a subject as the request actor.
|
||||
|
||||
Presumes that you have already loaded your user resource(s) into the
|
||||
connection's assigns.
|
||||
|
||||
Uses `Ash.PlugHelpers` to streamline integration with `AshGraphql` and
|
||||
`AshJsonApi`.
|
||||
|
||||
## Examples
|
||||
|
||||
Setting the actor for a AshGraphql API using `Plug.Router`.
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.ApiRouter do
|
||||
use Plug.Router
|
||||
import MyApp.AuthPlug
|
||||
|
||||
plug :retrieve_from_bearer
|
||||
plug :set_actor, :user
|
||||
|
||||
forward "/gql",
|
||||
to: Absinthe.Plug,
|
||||
init_opts: [schema: MyApp.Schema]
|
||||
end
|
||||
```
|
||||
"""
|
||||
@spec set_actor(Conn.t(), subject_name :: atom) :: Conn.t()
|
||||
def set_actor(conn, subject_name) do
|
||||
current_subject_name =
|
||||
subject_name
|
||||
|> current_subject_name()
|
||||
|
||||
actor =
|
||||
conn
|
||||
|> Map.get(:assigns, %{})
|
||||
|> Map.get(current_subject_name)
|
||||
|
||||
conn
|
||||
|> PlugHelpers.set_actor(actor)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Store result in private.
|
||||
|
@ -136,4 +179,9 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
def private_store(conn, {:failure, reason})
|
||||
when is_nil(reason) or is_map(reason),
|
||||
do: Conn.put_private(conn, :authentication_result, {:failure, reason})
|
||||
|
||||
# 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}")
|
||||
end
|
||||
|
|
108
lib/ash_authentication/plug/macros.ex
Normal file
108
lib/ash_authentication/plug/macros.ex
Normal file
|
@ -0,0 +1,108 @@
|
|||
defmodule AshAuthentication.Plug.Macros do
|
||||
@moduledoc """
|
||||
Generators used within `AshAuthentication.Plug.__using_/1`.
|
||||
"""
|
||||
|
||||
alias Ash.Api
|
||||
alias AshAuthentication.Plug.Helpers
|
||||
alias Plug.Conn
|
||||
alias Spark.Dsl.Extension
|
||||
|
||||
@doc """
|
||||
Generates the subject name validation code for the auth plug.
|
||||
"""
|
||||
@spec validate_subject_name_uniqueness(atom) :: Macro.t()
|
||||
defmacro validate_subject_name_uniqueness(otp_app) do
|
||||
quote do
|
||||
require Ash.Api.Info
|
||||
|
||||
unquote(otp_app)
|
||||
|> Application.compile_env(:ash_apis, [])
|
||||
|> Stream.flat_map(&Api.Info.depend_on_resources(&1))
|
||||
|> Stream.map(&{&1, Extension.get_persisted(&1, :authentication)})
|
||||
|> Stream.reject(&(elem(&1, 1) == nil))
|
||||
|> Stream.map(&{elem(&1, 0), elem(&1, 1).subject_name})
|
||||
|> Enum.group_by(&elem(&1, 1), &elem(&1, 0))
|
||||
|> Enum.reject(&(length(elem(&1, 1)) < 2))
|
||||
|> case do
|
||||
[] ->
|
||||
nil
|
||||
|
||||
duplicates ->
|
||||
import AshAuthentication.Utils, only: [to_sentence: 2]
|
||||
|
||||
duplicates =
|
||||
duplicates
|
||||
|> Enum.map(fn {subject_name, resources} ->
|
||||
resources =
|
||||
resources
|
||||
|> Enum.map(&"`#{inspect(&1)}`")
|
||||
|> to_sentence(final: "and")
|
||||
|
||||
" `#{subject_name}`: #{resources}\n"
|
||||
end)
|
||||
|
||||
raise """
|
||||
Error: There are multiple resources configured with the same subject name.
|
||||
|
||||
This is bad because we will be unable to correctly convert between subjects and resources.
|
||||
|
||||
#{duplicates}
|
||||
"""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates the `load_from_session/2` plug with the `otp_app` prefilled.
|
||||
"""
|
||||
@spec define_load_from_session(atom) :: Macro.t()
|
||||
defmacro define_load_from_session(otp_app) do
|
||||
quote do
|
||||
@doc """
|
||||
Attempt to retrieve all users 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))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates the `load_from_bearer/2` plug with the `otp_app` prefilled.
|
||||
"""
|
||||
@spec define_load_from_bearer(atom) :: Macro.t()
|
||||
defmacro define_load_from_bearer(otp_app) do
|
||||
quote do
|
||||
@doc """
|
||||
Attempt to retrieve users 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
|
||||
|
||||
@doc """
|
||||
Generates the `revoke_bearer_tokens/2` plug with the `otp_app` prefilled.
|
||||
"""
|
||||
@spec define_revoke_bearer_tokens(atom) :: Macro.t()
|
||||
defmacro define_revoke_bearer_tokens(otp_app) do
|
||||
quote do
|
||||
@doc """
|
||||
Revoke all authorization header(s).
|
||||
|
||||
Any bearer-style authorization headers will have their tokens revoked.
|
||||
A wrapper around `AshAuthentication.Plug.Helpers.revoke_bearer_tokens/2` with the `otp_app` already present.
|
||||
"""
|
||||
@spec revoke_bearer_tokens(Conn.t(), any) :: Conn.t()
|
||||
def revoke_bearer_tokens(conn, _opts),
|
||||
do: Helpers.revoke_bearer_tokens(conn, unquote(otp_app))
|
||||
end
|
||||
end
|
||||
end
|
6
mix.exs
6
mix.exs
|
@ -77,11 +77,15 @@ defmodule AshAuthentication.MixProject do
|
|||
# Run "mix help deps" to learn about dependencies.
|
||||
defp deps do
|
||||
[
|
||||
{:ash, "~> 2.2"},
|
||||
{:ash, "~> 2.3"},
|
||||
{:bcrypt_elixir, "~> 3.0", optional: true},
|
||||
{:jason, "~> 1.4"},
|
||||
{:joken, "~> 2.5"},
|
||||
{:plug, "~> 1.13"},
|
||||
{:absinthe_plug, "~> 1.5", only: [:dev, :test]},
|
||||
# These two can be changed back to hex once the next release goes out.
|
||||
{:ash_graphql, github: "ash-project/ash_graphql", only: [:dev, :test]},
|
||||
{:ash_json_api, github: "ash-project/ash_json_api", only: [:dev, :test]},
|
||||
{:ash_postgres, "~> 1.1", only: [:dev, :test]},
|
||||
{:credo, "~> 1.6", only: [:dev, :test], runtime: false},
|
||||
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
|
||||
|
|
12
mix.lock
12
mix.lock
|
@ -1,15 +1,21 @@
|
|||
%{
|
||||
"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"},
|
||||
"absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"},
|
||||
"absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"},
|
||||
"ash": {:hex, :ash, "2.3.0", "3f47a8f1f273a8fce66ac48ef146f4f7a51a6e50d26f50c2f650fbb976e6f5a8", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:spark, "~> 0.2", [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", "1540d43533b2c9caa9602209035f33ec2e32240df53d289fc196766dc0e3b510"},
|
||||
"ash_graphql": {:git, "https://github.com/ash-project/ash_graphql.git", "57e42cac6b7c58f96ee469c70be53b14d7135aa3", []},
|
||||
"ash_json_api": {:git, "https://github.com/ash-project/ash_json_api.git", "50b2785f31e9e8071b12942387e08b9f24a8602a", []},
|
||||
"ash_postgres": {:hex, :ash_postgres, "1.1.1", "2bbc2b39d9e387f89b964b29b042f88dd352b71e486d9aea7f9390ab1db3ced4", [:mix], [{:ash, "~> 2.1", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "fe47a6e629b6b23ce17c1d70b1bd4b3fd732df513b67126514fb88be86a6439e"},
|
||||
"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"},
|
||||
"conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"},
|
||||
"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"},
|
||||
"dataloader": {:hex, :dataloader, "1.0.10", "a42f07641b1a0572e0b21a2a5ae1be11da486a6790f3d0d14512d96ff3e3bbe9", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "54cd70cec09addf4b2ace14cc186a283a149fd4d3ec5475b155951bf33cd963f"},
|
||||
"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"},
|
||||
|
@ -31,6 +37,7 @@
|
|||
"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"},
|
||||
"json_xema": {:hex, :json_xema, "0.4.2", "85de190f597a98ce9da436b8a59c97ef561a6ab6017255df8b494babefd6fb10", [:mix], [{:conv_case, "~> 0.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:xema, "~> 0.11", [hex: :xema, repo: "hexpm", optional: false]}], "hexpm", "5516213758667d21669e0d63ea287238d277519527bac6c02140a5e34c1fda80"},
|
||||
"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"},
|
||||
|
@ -46,8 +53,9 @@
|
|||
"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.2.0", "501ce2c3fe46876fcfea4831168ef7f826c7cbbd8ede8e2c131ec27af5f07d75", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "d1dfdbce04e674bae337fa439e0e75087e7006cc8227567f035b9be0013a23c3"},
|
||||
"spark": {:hex, :spark, "0.2.1", "4f76234fce4bf48a6236e2268fba4d33c441ed8e30944785852c483a7aed231c", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "29033cb2ebecfff5ceff5209cca06c8e1e7ce8c1da189676de19cdc07d146b43"},
|
||||
"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"},
|
||||
"xema": {:hex, :xema, "0.17.0", "982e397ce0af55cdf1c6bf9c5ee6e20c5ea4a24e58e5266339cfff0dadbfa01e", [:mix], [{:conv_case, "~> 0.2.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9020afc75c5b9fba1c5875fd735a19c3c544db058cd97ef4c4675e479fc8bcbe"},
|
||||
}
|
||||
|
|
39
test/ash_authentication/plug/defaults_test.exs
Normal file
39
test/ash_authentication/plug/defaults_test.exs
Normal file
|
@ -0,0 +1,39 @@
|
|||
defmodule AshAuthentication.Plug.DefaultsTest do
|
||||
@moduledoc false
|
||||
use AshAuthentication.DataCase, async: true
|
||||
alias AshAuthentication.{Plug.Defaults, SessionPipeline}
|
||||
import Plug.Test, only: [conn: 3]
|
||||
|
||||
setup do
|
||||
conn =
|
||||
:get
|
||||
|> conn("/", %{})
|
||||
|> SessionPipeline.call([])
|
||||
|
||||
{:ok, conn: conn}
|
||||
end
|
||||
|
||||
describe "handle_success/3" do
|
||||
test "it returns 200 and a basic message", %{conn: conn} do
|
||||
user = build_user()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> Defaults.handle_success(user, user.__metadata__.token)
|
||||
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body =~ ~r/access granted/i
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_failure/2" do
|
||||
test "it returns 401 and a basic message", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> Defaults.handle_failure(:arbitrary_reason)
|
||||
|
||||
assert conn.status == 401
|
||||
assert conn.resp_body =~ ~r/access denied/i
|
||||
end
|
||||
end
|
||||
end
|
119
test/ash_authentication/plug/helpers_test.exs
Normal file
119
test/ash_authentication/plug/helpers_test.exs
Normal file
|
@ -0,0 +1,119 @@
|
|||
defmodule AshAuthentication.Plug.HelpersTest do
|
||||
@moduledoc false
|
||||
use AshAuthentication.DataCase, async: true
|
||||
alias AshAuthentication.{Plug.Helpers, SessionPipeline}
|
||||
import Plug.Test, only: [conn: 3]
|
||||
alias Plug.Conn
|
||||
|
||||
setup do
|
||||
conn =
|
||||
:get
|
||||
|> conn("/", %{})
|
||||
|> SessionPipeline.call([])
|
||||
|
||||
{:ok, conn: conn}
|
||||
end
|
||||
|
||||
describe "store_in_session/2" do
|
||||
test "it stores the user in the session", %{conn: conn} do
|
||||
user = build_user()
|
||||
subject = AshAuthentication.resource_to_subject(user)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> Helpers.store_in_session(user)
|
||||
|
||||
assert conn.private.plug_session["user_with_username"] == subject
|
||||
end
|
||||
end
|
||||
|
||||
describe "load_subjects/2" do
|
||||
test "it loads the subjects listed" do
|
||||
user = build_user()
|
||||
subject = AshAuthentication.resource_to_subject(user)
|
||||
|
||||
rx_users = Helpers.load_subjects([subject], :ash_authentication)
|
||||
|
||||
assert rx_users[:current_user_with_username].id == user.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "retrieve_from_session/2" do
|
||||
test "it loads any subjects stored in the session", %{conn: conn} do
|
||||
user = build_user()
|
||||
subject = AshAuthentication.resource_to_subject(user)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> Conn.put_session("user_with_username", subject)
|
||||
|> Helpers.retrieve_from_session(:ash_authentication)
|
||||
|
||||
assert conn.assigns.current_user_with_username.id == user.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "retrieve_from_bearer/2" do
|
||||
test "it loads any subjects from authorization headers", %{conn: conn} do
|
||||
user = build_user()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}")
|
||||
|> Helpers.retrieve_from_bearer(:ash_authentication)
|
||||
|
||||
assert conn.assigns.current_user_with_username.id == user.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "revoke_bearer_tokens/2" do
|
||||
test "it revokes any tokens in the authorization headers", %{conn: conn} do
|
||||
user = build_user()
|
||||
|
||||
{:ok, %{"jti" => jti}} =
|
||||
user.__metadata__.token
|
||||
|> Joken.peek_claims()
|
||||
|
||||
conn
|
||||
|> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}")
|
||||
|> Helpers.revoke_bearer_tokens(:ash_authentication)
|
||||
|
||||
assert AshAuthentication.TokenRevocation.revoked?(user.__struct__, jti)
|
||||
end
|
||||
end
|
||||
|
||||
describe "set_actor/2" do
|
||||
alias Ash.PlugHelpers
|
||||
|
||||
test "it sets the actor when there is a `current_` resource in the assigns", %{conn: conn} do
|
||||
user = build_user()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> Conn.assign(:current_user_with_username, user)
|
||||
|> Helpers.set_actor(:user_with_username)
|
||||
|
||||
assert PlugHelpers.get_actor(conn) == user
|
||||
end
|
||||
|
||||
test "it sets the actor to `nil` otherwise", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> Helpers.set_actor(:user_with_username)
|
||||
|
||||
refute PlugHelpers.get_actor(conn)
|
||||
end
|
||||
end
|
||||
|
||||
describe "private_store/2" do
|
||||
test "it stores the authentication result in the conn's private", %{conn: conn} do
|
||||
user = build_user()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> Conn.put_private(:authenticator, %{resource: user.__struct__})
|
||||
|> Helpers.private_store({:success, user})
|
||||
|
||||
assert conn.private.authentication_result == {:success, user}
|
||||
end
|
||||
end
|
||||
end
|
174
test/ash_authentication/plug_test.exs
Normal file
174
test/ash_authentication/plug_test.exs
Normal file
|
@ -0,0 +1,174 @@
|
|||
defmodule AshAuthentication.PlugTest do
|
||||
@moduledoc false
|
||||
use AshAuthentication.DataCase, async: true
|
||||
use Mimic
|
||||
alias AshAuthentication.Plug.{Defaults, Helpers}
|
||||
alias AshAuthentication.SessionPipeline
|
||||
alias Example.AuthPlug
|
||||
import Plug.Test, only: [conn: 3]
|
||||
|
||||
describe "handle_success/3" do
|
||||
test "it is called when authentication is successful" do
|
||||
password = password()
|
||||
user = build_user(password: password, password_confirmation: password)
|
||||
|
||||
opts = AuthPlug.init([])
|
||||
|
||||
%{status: status, resp_body: resp} =
|
||||
:post
|
||||
|> conn("/user_with_username/password/callback", %{
|
||||
"user_with_username" => %{
|
||||
"username" => to_string(user.username),
|
||||
"password" => password,
|
||||
"action" => "sign_in"
|
||||
}
|
||||
})
|
||||
|> SessionPipeline.call([])
|
||||
|> AuthPlug.call(opts)
|
||||
|
||||
resp = Jason.decode!(resp)
|
||||
|
||||
assert status == 200
|
||||
assert resp["user"]["id"] == user.id
|
||||
assert resp["user"]["username"] == to_string(user.username)
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_failure/2" do
|
||||
test "it is called when authentication is unsuccessful" do
|
||||
opts = AuthPlug.init([])
|
||||
|
||||
%{status: status, resp_body: resp} =
|
||||
:post
|
||||
|> conn("/user_with_username/password/callback", %{
|
||||
"user_with_username" => %{
|
||||
"username" => username(),
|
||||
"password" => password(),
|
||||
"action" => "sign_in"
|
||||
}
|
||||
})
|
||||
|> SessionPipeline.call([])
|
||||
|> AuthPlug.call(opts)
|
||||
|
||||
resp = Jason.decode!(resp)
|
||||
|
||||
assert status == 401
|
||||
assert resp["status"] == "failed"
|
||||
assert resp["reason"] =~ ~r/Forbidden/
|
||||
end
|
||||
end
|
||||
|
||||
describe "load_from_session/2" do
|
||||
test "it delegates to Helpers.retrieve_from_session/2" do
|
||||
conn = conn(:get, "/", %{})
|
||||
|
||||
Helpers
|
||||
|> expect(:retrieve_from_session, fn rx_conn, otp_app ->
|
||||
assert otp_app == :ash_authentication
|
||||
assert conn == rx_conn
|
||||
end)
|
||||
|
||||
conn
|
||||
|> AuthPlug.load_from_session([])
|
||||
end
|
||||
end
|
||||
|
||||
describe "load_from_bearer/2" do
|
||||
test "it delegates to Helpers.retrieve_from_bearer/2" do
|
||||
conn = conn(:get, "/", %{})
|
||||
|
||||
Helpers
|
||||
|> expect(:retrieve_from_bearer, fn rx_conn, otp_app ->
|
||||
assert otp_app == :ash_authentication
|
||||
assert conn == rx_conn
|
||||
end)
|
||||
|
||||
conn
|
||||
|> AuthPlug.load_from_bearer([])
|
||||
end
|
||||
end
|
||||
|
||||
describe "revoke_bearer_tokens/2" do
|
||||
test "it delegates to Helpers.revoke_bearer_tokens/2" do
|
||||
conn = conn(:get, "/", %{})
|
||||
|
||||
Helpers
|
||||
|> expect(:revoke_bearer_tokens, fn rx_conn, otp_app ->
|
||||
assert otp_app == :ash_authentication
|
||||
assert conn == rx_conn
|
||||
end)
|
||||
|
||||
conn
|
||||
|> AuthPlug.revoke_bearer_tokens([])
|
||||
end
|
||||
end
|
||||
|
||||
describe "set_actor/2" do
|
||||
test "it delegates to Helpers.set_actor/2" do
|
||||
conn = conn(:get, "/", %{})
|
||||
|
||||
Helpers
|
||||
|> expect(:set_actor, fn rx_conn, subject_name ->
|
||||
assert subject_name == :user_with_username
|
||||
assert conn == rx_conn
|
||||
end)
|
||||
|
||||
conn
|
||||
|> AuthPlug.set_actor(:user_with_username)
|
||||
end
|
||||
end
|
||||
|
||||
describe "store_in_session/2" do
|
||||
test "it delegates to Helpers.store_in_session/2" do
|
||||
user = build_user()
|
||||
|
||||
conn = conn(:get, "/", %{})
|
||||
|
||||
Helpers
|
||||
|> expect(:store_in_session, fn rx_conn, rx_user ->
|
||||
assert rx_user == user
|
||||
assert conn == rx_conn
|
||||
end)
|
||||
|
||||
conn
|
||||
|> AuthPlug.store_in_session(user)
|
||||
end
|
||||
end
|
||||
|
||||
describe "__using__/1" do
|
||||
defmodule WithDefaults do
|
||||
@moduledoc false
|
||||
use AshAuthentication.Plug, otp_app: :ash_authentication
|
||||
end
|
||||
|
||||
test "it uses the default handle_success/3" do
|
||||
conn = conn(:get, "/", %{})
|
||||
user = build_user()
|
||||
token = Ecto.UUID.generate()
|
||||
|
||||
Defaults
|
||||
|> expect(:handle_success, fn rx_conn, rx_user, rx_token ->
|
||||
assert rx_conn == conn
|
||||
assert rx_user == user
|
||||
assert rx_token == token
|
||||
end)
|
||||
|
||||
conn
|
||||
|> WithDefaults.handle_success(user, token)
|
||||
end
|
||||
|
||||
test "it uses the default handle_failure/2" do
|
||||
conn = conn(:get, "/", %{})
|
||||
reason = Ecto.UUID.generate()
|
||||
|
||||
Defaults
|
||||
|> expect(:handle_failure, fn rx_conn, rx_reason ->
|
||||
assert rx_conn == conn
|
||||
assert rx_reason == reason
|
||||
end)
|
||||
|
||||
conn
|
||||
|> WithDefaults.handle_failure(reason)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -60,11 +60,12 @@ defmodule AshAuthentication.DataCase do
|
|||
def username, do: Faker.Internet.user_name()
|
||||
def password, do: Faker.Lorem.words(4) |> Enum.join(" ")
|
||||
|
||||
def build_user(attrs \\ %{}) do
|
||||
def build_user(attrs \\ []) do
|
||||
password = password()
|
||||
|
||||
attrs =
|
||||
attrs
|
||||
|> Map.new()
|
||||
|> Map.put_new(:username, username())
|
||||
|> Map.put_new(:password, password)
|
||||
|> Map.put_new(:password_confirmation, password)
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
defmodule Example do
|
||||
@moduledoc false
|
||||
use Ash.Api, otp_app: :ash_authentication
|
||||
use Ash.Api, otp_app: :ash_authentication, extensions: [AshGraphql.Api, AshJsonApi.Api]
|
||||
|
||||
resources do
|
||||
registry Example.Registry
|
||||
end
|
||||
|
||||
json_api do
|
||||
prefix "/api"
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,19 +3,32 @@ defmodule Example.AuthPlug do
|
|||
use AshAuthentication.Plug, otp_app: :ash_authentication
|
||||
|
||||
@impl true
|
||||
def handle_success(conn, actor, token) do
|
||||
def handle_success(conn, user, token) do
|
||||
conn
|
||||
|> store_in_session(actor)
|
||||
|> send_resp(200, """
|
||||
Token: #{token}
|
||||
|
||||
Actor: #{inspect(actor)}
|
||||
""")
|
||||
|> store_in_session(user)
|
||||
|> put_resp_header("content-type", "application/json")
|
||||
|> send_resp(
|
||||
200,
|
||||
Jason.encode!(%{
|
||||
token: token,
|
||||
user: %{
|
||||
id: user.id,
|
||||
username: user.username
|
||||
}
|
||||
})
|
||||
)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_failure(conn, _) do
|
||||
def handle_failure(conn, reason) do
|
||||
conn
|
||||
|> send_resp(401, "Sorry mate")
|
||||
|> put_resp_header("content-type", "application/json")
|
||||
|> send_resp(
|
||||
401,
|
||||
Jason.encode!(%{
|
||||
status: "failed",
|
||||
reason: inspect(reason)
|
||||
})
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
17
test/support/example/current_user_read.ex
Normal file
17
test/support/example/current_user_read.ex
Normal file
|
@ -0,0 +1,17 @@
|
|||
defmodule Example.CurrentUserRead do
|
||||
@moduledoc """
|
||||
There's no need to actually go to the database to get the current user, when
|
||||
we know it will already be in the context.
|
||||
|
||||
Here we just check that the actor is the same type of resource as is being
|
||||
asked for.
|
||||
"""
|
||||
use Ash.Resource.ManualRead
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def read(%{resource: resource}, _, _, %{actor: actor}) when is_struct(actor, resource),
|
||||
do: {:ok, [actor]}
|
||||
|
||||
def read(_, _, _, _), do: {:ok, []}
|
||||
end
|
16
test/support/example/schema.ex
Normal file
16
test/support/example/schema.ex
Normal file
|
@ -0,0 +1,16 @@
|
|||
defmodule Example.Schema do
|
||||
@moduledoc false
|
||||
use Absinthe.Schema
|
||||
|
||||
@apis [Example]
|
||||
|
||||
use AshGraphql, apis: @apis
|
||||
|
||||
def context(ctx) do
|
||||
AshGraphql.add_context(ctx, @apis)
|
||||
end
|
||||
|
||||
def plugins do
|
||||
[Absinthe.Middleware.Dataloader | Absinthe.Plugin.defaults()]
|
||||
end
|
||||
end
|
|
@ -2,7 +2,12 @@ defmodule Example.UserWithUsername do
|
|||
@moduledoc false
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication]
|
||||
extensions: [
|
||||
AshAuthentication,
|
||||
AshAuthentication.PasswordAuthentication,
|
||||
AshGraphql.Resource,
|
||||
AshJsonApi.Resource
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: Ecto.UUID.t(),
|
||||
|
@ -16,22 +21,57 @@ defmodule Example.UserWithUsername do
|
|||
uuid_primary_key(:id)
|
||||
|
||||
attribute(:username, :ci_string, allow_nil?: false)
|
||||
attribute(:hashed_password, :string, allow_nil?: false, sensitive?: true)
|
||||
attribute(:hashed_password, :string, allow_nil?: false, sensitive?: true, private?: true)
|
||||
|
||||
create_timestamp(:created_at)
|
||||
update_timestamp(:updated_at)
|
||||
end
|
||||
|
||||
actions do
|
||||
read :read do
|
||||
primary? true
|
||||
end
|
||||
|
||||
destroy :destroy do
|
||||
primary? true
|
||||
end
|
||||
|
||||
read :current_user do
|
||||
get? true
|
||||
manual Example.CurrentUserRead
|
||||
end
|
||||
end
|
||||
|
||||
code_interface do
|
||||
define_for(Example)
|
||||
end
|
||||
|
||||
graphql do
|
||||
type :user
|
||||
|
||||
queries do
|
||||
get(:get_user, :read)
|
||||
list(:list_users, :read)
|
||||
read_one(:current_user, :current_user)
|
||||
end
|
||||
|
||||
mutations do
|
||||
create :register, :register
|
||||
end
|
||||
end
|
||||
|
||||
json_api do
|
||||
type "user"
|
||||
|
||||
routes do
|
||||
base("/users")
|
||||
get(:read)
|
||||
get(:current_user, route: "/me")
|
||||
index(:read)
|
||||
post(:register)
|
||||
end
|
||||
end
|
||||
|
||||
postgres do
|
||||
table("user_with_username")
|
||||
repo(Example.Repo)
|
||||
|
|
23
test/support/session_pipeline.ex
Normal file
23
test/support/session_pipeline.ex
Normal file
|
@ -0,0 +1,23 @@
|
|||
defmodule AshAuthentication.SessionPipeline do
|
||||
@moduledoc """
|
||||
A simple plug pipeline that ensures that the session is set up ready to be consumed.
|
||||
"""
|
||||
use Plug.Builder
|
||||
import Ecto.UUID, only: [generate: 0]
|
||||
|
||||
plug(:set_secret)
|
||||
|
||||
plug(Plug.Session,
|
||||
store: :cookie,
|
||||
key: inspect(__MODULE__),
|
||||
encryption_salt: generate(),
|
||||
signing_salt: generate()
|
||||
)
|
||||
|
||||
plug(:fetch_session)
|
||||
|
||||
@doc false
|
||||
def set_secret(conn, _) do
|
||||
put_in(conn.secret_key_base, generate() <> generate())
|
||||
end
|
||||
end
|
|
@ -1,2 +1,4 @@
|
|||
Mimic.copy(AshAuthentication.Plug.Defaults)
|
||||
Mimic.copy(AshAuthentication.Plug.Helpers)
|
||||
Mimic.copy(AshAuthentication.TokenRevocation)
|
||||
ExUnit.start(capture_log: true)
|
||||
|
|
Loading…
Reference in a new issue