From a939dde9b917c072cdf10c4b0913a9886a4b0231 Mon Sep 17 00:00:00 2001 From: James Harton <59449+jimsynz@users.noreply.github.com> Date: Tue, 25 Oct 2022 11:07:07 +1300 Subject: [PATCH] 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. --- .devcontainer/docker-compose.yml | 1 + .doctor.exs | 4 +- .formatter.exs | 27 ++- config/dev.exs | 21 ++ config/test.exs | 23 ++ dev/dev_server.ex | 24 ++ dev/dev_server/clear_session.ex | 23 ++ dev/dev_server/plug.ex | 24 ++ dev/dev_server/session.ex | 17 ++ dev/dev_server/test_page.ex | 37 +++ dev/dev_server/test_page.html.eex | 53 +++++ dev/dev_server/token_check.ex | 26 +++ lib/ash_authentication.ex | 174 +++++++++++++- lib/ash_authentication/application.ex | 26 ++- lib/ash_authentication/bcrypt_provider.ex | 29 +++ .../errors/authentication_failed.ex | 14 ++ lib/ash_authentication/hash_provider.ex | 22 ++ lib/ash_authentication/info.ex | 10 + lib/ash_authentication/info_generator.ex | 170 ++++++++++++++ lib/ash_authentication/jwt.ex | 131 +++++++++++ lib/ash_authentication/jwt/config.ex | 144 ++++++++++++ .../password_authentication.ex | 175 +++++++++++++++ .../password_authentication/actions.ex | 52 +++++ .../generate_token_change.ex | 24 ++ .../hash_password_change.ex | 32 +++ .../password_authentication/html.ex | 96 ++++++++ .../password_authentication/info.ex | 9 + .../password_confirmation_validation.ex | 30 +++ .../password_authentication/plug.ex | 55 +++++ .../sign_in_preparation.ex | 55 +++++ .../password_authentication/transformer.ex | 198 ++++++++++++++++ .../user_validations.ex | 191 ++++++++++++++++ lib/ash_authentication/plug.ex | 194 ++++++++++++++++ lib/ash_authentication/plug/dispatcher.ex | 56 +++++ lib/ash_authentication/plug/helpers.ex | 139 ++++++++++++ lib/ash_authentication/plug/router.ex | 65 ++++++ lib/ash_authentication/provider.ex | 40 ++++ lib/ash_authentication/token_revocation.ex | 172 ++++++++++++++ .../token_revocation/expunger.ex | 53 +++++ .../token_revocation/info.ex | 9 + .../token_revocation/revoke_token_change.ex | 32 +++ .../token_revocation/transformer.ex | 212 ++++++++++++++++++ lib/ash_authentication/transformer.ex | 155 +++++++++++++ lib/ash_authentication/utils.ex | 80 +++++++ lib/ash_authentication/validations.ex | 109 +++++++++ lib/ash_authentication/validations/action.ex | 161 +++++++++++++ .../validations/attribute.ex | 79 +++++++ mix.exs | 32 ++- mix.lock | 37 ++- .../20221002235524_install_2_extensions.exs | 21 ++ .../20221002235526_migrate_resources1.exs | 23 ++ ...21020042559_add_token_revocation_table.exs | 28 +++ priv/resource_snapshots/extensions.json | 6 + .../token_revocations/20221020042559.json | 39 ++++ .../user_with_username/20221002235526.json | 69 ++++++ .../user_with_username/20221020042559.json | 78 +++++++ test/ash_authentication/jwt/config_test.exs | 73 ++++++ test/ash_authentication/jwt_test.exs | 77 +++++++ .../password_authentication/action_test.exs | 166 ++++++++++++++ .../password_authentication/identity_test.exs | 49 ++++ .../token_revocation_test.exs | 24 ++ test/ash_authentication_test.exs | 14 +- test/support/data_case.ex | 76 +++++++ test/support/example.ex | 8 + test/support/example/auth_plug.ex | 21 ++ test/support/example/registry.ex | 9 + test/support/example/repo.ex | 7 + test/support/example/token_revocation.ex | 24 ++ test/support/example/user_with_username.ex | 57 +++++ test/test_helper.exs | 3 +- 70 files changed, 4381 insertions(+), 33 deletions(-) create mode 100644 dev/dev_server.ex create mode 100644 dev/dev_server/clear_session.ex create mode 100644 dev/dev_server/plug.ex create mode 100644 dev/dev_server/session.ex create mode 100644 dev/dev_server/test_page.ex create mode 100644 dev/dev_server/test_page.html.eex create mode 100644 dev/dev_server/token_check.ex create mode 100644 lib/ash_authentication/bcrypt_provider.ex create mode 100644 lib/ash_authentication/errors/authentication_failed.ex create mode 100644 lib/ash_authentication/hash_provider.ex create mode 100644 lib/ash_authentication/info.ex create mode 100644 lib/ash_authentication/info_generator.ex create mode 100644 lib/ash_authentication/jwt.ex create mode 100644 lib/ash_authentication/jwt/config.ex create mode 100644 lib/ash_authentication/password_authentication.ex create mode 100644 lib/ash_authentication/password_authentication/actions.ex create mode 100644 lib/ash_authentication/password_authentication/generate_token_change.ex create mode 100644 lib/ash_authentication/password_authentication/hash_password_change.ex create mode 100644 lib/ash_authentication/password_authentication/html.ex create mode 100644 lib/ash_authentication/password_authentication/info.ex create mode 100644 lib/ash_authentication/password_authentication/password_confirmation_validation.ex create mode 100644 lib/ash_authentication/password_authentication/plug.ex create mode 100644 lib/ash_authentication/password_authentication/sign_in_preparation.ex create mode 100644 lib/ash_authentication/password_authentication/transformer.ex create mode 100644 lib/ash_authentication/password_authentication/user_validations.ex create mode 100644 lib/ash_authentication/plug.ex create mode 100644 lib/ash_authentication/plug/dispatcher.ex create mode 100644 lib/ash_authentication/plug/helpers.ex create mode 100644 lib/ash_authentication/plug/router.ex create mode 100644 lib/ash_authentication/provider.ex create mode 100644 lib/ash_authentication/token_revocation.ex create mode 100644 lib/ash_authentication/token_revocation/expunger.ex create mode 100644 lib/ash_authentication/token_revocation/info.ex create mode 100644 lib/ash_authentication/token_revocation/revoke_token_change.ex create mode 100644 lib/ash_authentication/token_revocation/transformer.ex create mode 100644 lib/ash_authentication/transformer.ex create mode 100644 lib/ash_authentication/utils.ex create mode 100644 lib/ash_authentication/validations.ex create mode 100644 lib/ash_authentication/validations/action.ex create mode 100644 lib/ash_authentication/validations/attribute.ex create mode 100644 priv/repo/migrations/20221002235524_install_2_extensions.exs create mode 100644 priv/repo/migrations/20221002235526_migrate_resources1.exs create mode 100644 priv/repo/migrations/20221020042559_add_token_revocation_table.exs create mode 100644 priv/resource_snapshots/extensions.json create mode 100644 priv/resource_snapshots/repo/token_revocations/20221020042559.json create mode 100644 priv/resource_snapshots/repo/user_with_username/20221002235526.json create mode 100644 priv/resource_snapshots/repo/user_with_username/20221020042559.json create mode 100644 test/ash_authentication/jwt/config_test.exs create mode 100644 test/ash_authentication/jwt_test.exs create mode 100644 test/ash_authentication/password_authentication/action_test.exs create mode 100644 test/ash_authentication/password_authentication/identity_test.exs create mode 100644 test/ash_authentication/token_revocation_test.exs create mode 100644 test/support/data_case.ex create mode 100644 test/support/example.ex create mode 100644 test/support/example/auth_plug.ex create mode 100644 test/support/example/registry.ex create mode 100644 test/support/example/repo.ex create mode 100644 test/support/example/token_revocation.ex create mode 100644 test/support/example/user_with_username.ex diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 5484329..33483a8 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,4 +1,5 @@ version: "3.8" +name: ash_authentication volumes: apt-cache: {} diff --git a/.doctor.exs b/.doctor.exs index 1c250f4..e5edb7d 100644 --- a/.doctor.exs +++ b/.doctor.exs @@ -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, diff --git a/.formatter.exs b/.formatter.exs index d2cda26..29f5856 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -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 + ] ] diff --git a/config/dev.exs b/config/dev.exs index 945c764..ba29688 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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" diff --git a/config/test.exs b/config/test.exs index e69de29..5242761 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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" diff --git a/dev/dev_server.ex b/dev/dev_server.ex new file mode 100644 index 0000000..b62bc77 --- /dev/null +++ b/dev/dev_server.ex @@ -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 diff --git a/dev/dev_server/clear_session.ex b/dev/dev_server/clear_session.ex new file mode 100644 index 0000000..35a707e --- /dev/null +++ b/dev/dev_server/clear_session.ex @@ -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 diff --git a/dev/dev_server/plug.ex b/dev/dev_server/plug.ex new file mode 100644 index 0000000..b36f454 --- /dev/null +++ b/dev/dev_server/plug.ex @@ -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 diff --git a/dev/dev_server/session.ex b/dev/dev_server/session.ex new file mode 100644 index 0000000..5a81a23 --- /dev/null +++ b/dev/dev_server/session.ex @@ -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 diff --git a/dev/dev_server/test_page.ex b/dev/dev_server/test_page.ex new file mode 100644 index 0000000..e4a916f --- /dev/null +++ b/dev/dev_server/test_page.ex @@ -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 diff --git a/dev/dev_server/test_page.html.eex b/dev/dev_server/test_page.html.eex new file mode 100644 index 0000000..5ed8c2c --- /dev/null +++ b/dev/dev_server/test_page.html.eex @@ -0,0 +1,53 @@ + + + + Ash Authentication + + + +

Ash Authentication

+ <%= if Enum.any?(@resources) do %> +

Resources:

+ + <%= for config <- @resources do %> +

<%= inspect(config.subject_name) %> - <%= Ash.Api.Info.short_name(config.api) %> / <%= Ash.Resource.Info.short_name(config.resource) %>

+ + <%= 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 %> + +

Validate token

+
+ +
+ +
+ <% else %> +

+ No resources configured +
+ Please see the documentation for more information. +

+ <% end %> + + <%= if Enum.any?(@current_actors) do %> +

Current actors:

+ Clear session + + + + + + <%= for {name, actor} <- @current_actors do %> + + + + + <% end %> +
NameValue
@<%= name %>
<%= inspect actor, pretty: true %>
+ <% end %> + + + diff --git a/dev/dev_server/token_check.ex b/dev/dev_server/token_check.ex new file mode 100644 index 0000000..e2efb9e --- /dev/null +++ b/dev/dev_server/token_check.ex @@ -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 diff --git a/lib/ash_authentication.ex b/lib/ash_authentication.ex index 55e1d72..56fe2e4 100644 --- a/lib/ash_authentication.ex +++ b/lib/ash_authentication.ex @@ -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 diff --git a/lib/ash_authentication/application.ex b/lib/ash_authentication/application.ex index c71737c..a0dd6c0 100644 --- a/lib/ash_authentication/application.ex +++ b/lib/ash_authentication/application.ex @@ -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 diff --git a/lib/ash_authentication/bcrypt_provider.ex b/lib/ash_authentication/bcrypt_provider.ex new file mode 100644 index 0000000..9acdcdb --- /dev/null +++ b/lib/ash_authentication/bcrypt_provider.ex @@ -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 diff --git a/lib/ash_authentication/errors/authentication_failed.ex b/lib/ash_authentication/errors/authentication_failed.ex new file mode 100644 index 0000000..685e33d --- /dev/null +++ b/lib/ash_authentication/errors/authentication_failed.ex @@ -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 diff --git a/lib/ash_authentication/hash_provider.ex b/lib/ash_authentication/hash_provider.ex new file mode 100644 index 0000000..825b90f --- /dev/null +++ b/lib/ash_authentication/hash_provider.ex @@ -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 diff --git a/lib/ash_authentication/info.ex b/lib/ash_authentication/info.ex new file mode 100644 index 0000000..150c3c8 --- /dev/null +++ b/lib/ash_authentication/info.ex @@ -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 diff --git a/lib/ash_authentication/info_generator.ex b/lib/ash_authentication/info_generator.ex new file mode 100644 index 0000000..56098a9 --- /dev/null +++ b/lib/ash_authentication/info_generator.ex @@ -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 diff --git a/lib/ash_authentication/jwt.ex b/lib/ash_authentication/jwt.ex new file mode 100644 index 0000000..9570e69 --- /dev/null +++ b/lib/ash_authentication/jwt.ex @@ -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 diff --git a/lib/ash_authentication/jwt/config.ex b/lib/ash_authentication/jwt/config.ex new file mode 100644 index 0000000..6b88e65 --- /dev/null +++ b/lib/ash_authentication/jwt/config.ex @@ -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 diff --git a/lib/ash_authentication/password_authentication.ex b/lib/ash_authentication/password_authentication.ex new file mode 100644 index 0000000..4199d2e --- /dev/null +++ b/lib/ash_authentication/password_authentication.ex @@ -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 diff --git a/lib/ash_authentication/password_authentication/actions.ex b/lib/ash_authentication/password_authentication/actions.ex new file mode 100644 index 0000000..f523469 --- /dev/null +++ b/lib/ash_authentication/password_authentication/actions.ex @@ -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 diff --git a/lib/ash_authentication/password_authentication/generate_token_change.ex b/lib/ash_authentication/password_authentication/generate_token_change.ex new file mode 100644 index 0000000..75120b3 --- /dev/null +++ b/lib/ash_authentication/password_authentication/generate_token_change.ex @@ -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 diff --git a/lib/ash_authentication/password_authentication/hash_password_change.ex b/lib/ash_authentication/password_authentication/hash_password_change.ex new file mode 100644 index 0000000..f0654b3 --- /dev/null +++ b/lib/ash_authentication/password_authentication/hash_password_change.ex @@ -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 diff --git a/lib/ash_authentication/password_authentication/html.ex b/lib/ash_authentication/password_authentication/html.ex new file mode 100644 index 0000000..584afe0 --- /dev/null +++ b/lib/ash_authentication/password_authentication/html.ex @@ -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""" +
+ +
+ <%= if @legend do %><%= @legend %><% end %> + +
+ +
+ +
+
+ """, + [:assigns] + ) + + EEx.function_from_string( + :defp, + :render_register, + ~s""" +
+ +
+ <%= if @legend do %><%= @legend %><% end %> + +
+ +
+ <%= if @confirmation_required? do %> + +
+ <% end %> + +
+
+ """, + [: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 diff --git a/lib/ash_authentication/password_authentication/info.ex b/lib/ash_authentication/password_authentication/info.ex new file mode 100644 index 0000000..c307172 --- /dev/null +++ b/lib/ash_authentication/password_authentication/info.ex @@ -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 diff --git a/lib/ash_authentication/password_authentication/password_confirmation_validation.ex b/lib/ash_authentication/password_authentication/password_confirmation_validation.ex new file mode 100644 index 0000000..3d5f199 --- /dev/null +++ b/lib/ash_authentication/password_authentication/password_confirmation_validation.ex @@ -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 diff --git a/lib/ash_authentication/password_authentication/plug.ex b/lib/ash_authentication/password_authentication/plug.ex new file mode 100644 index 0000000..128be86 --- /dev/null +++ b/lib/ash_authentication/password_authentication/plug.ex @@ -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 diff --git a/lib/ash_authentication/password_authentication/sign_in_preparation.ex b/lib/ash_authentication/password_authentication/sign_in_preparation.ex new file mode 100644 index 0000000..25845b2 --- /dev/null +++ b/lib/ash_authentication/password_authentication/sign_in_preparation.ex @@ -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 diff --git a/lib/ash_authentication/password_authentication/transformer.ex b/lib/ash_authentication/password_authentication/transformer.ex new file mode 100644 index 0000000..80e21a5 --- /dev/null +++ b/lib/ash_authentication/password_authentication/transformer.ex @@ -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 diff --git a/lib/ash_authentication/password_authentication/user_validations.ex b/lib/ash_authentication/password_authentication/user_validations.ex new file mode 100644 index 0000000..bb712b2 --- /dev/null +++ b/lib/ash_authentication/password_authentication/user_validations.ex @@ -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 diff --git a/lib/ash_authentication/plug.ex b/lib/ash_authentication/plug.ex new file mode 100644 index 0000000..bcd4010 --- /dev/null +++ b/lib/ash_authentication/plug.ex @@ -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 diff --git a/lib/ash_authentication/plug/dispatcher.ex b/lib/ash_authentication/plug/dispatcher.ex new file mode 100644 index 0000000..cb8f03c --- /dev/null +++ b/lib/ash_authentication/plug/dispatcher.ex @@ -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 diff --git a/lib/ash_authentication/plug/helpers.ex b/lib/ash_authentication/plug/helpers.ex new file mode 100644 index 0000000..b4c4017 --- /dev/null +++ b/lib/ash_authentication/plug/helpers.ex @@ -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 diff --git a/lib/ash_authentication/plug/router.ex b/lib/ash_authentication/plug/router.ex new file mode 100644 index 0000000..631fd9b --- /dev/null +++ b/lib/ash_authentication/plug/router.ex @@ -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 diff --git a/lib/ash_authentication/provider.ex b/lib/ash_authentication/provider.ex new file mode 100644 index 0000000..8328696 --- /dev/null +++ b/lib/ash_authentication/provider.ex @@ -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 diff --git a/lib/ash_authentication/token_revocation.ex b/lib/ash_authentication/token_revocation.ex new file mode 100644 index 0000000..5a79b5f --- /dev/null +++ b/lib/ash_authentication/token_revocation.ex @@ -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 diff --git a/lib/ash_authentication/token_revocation/expunger.ex b/lib/ash_authentication/token_revocation/expunger.ex new file mode 100644 index 0000000..a2e1c1a --- /dev/null +++ b/lib/ash_authentication/token_revocation/expunger.ex @@ -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 diff --git a/lib/ash_authentication/token_revocation/info.ex b/lib/ash_authentication/token_revocation/info.ex new file mode 100644 index 0000000..99463c4 --- /dev/null +++ b/lib/ash_authentication/token_revocation/info.ex @@ -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 diff --git a/lib/ash_authentication/token_revocation/revoke_token_change.ex b/lib/ash_authentication/token_revocation/revoke_token_change.ex new file mode 100644 index 0000000..b4bbfb4 --- /dev/null +++ b/lib/ash_authentication/token_revocation/revoke_token_change.ex @@ -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 diff --git a/lib/ash_authentication/token_revocation/transformer.ex b/lib/ash_authentication/token_revocation/transformer.ex new file mode 100644 index 0000000..cea3ee0 --- /dev/null +++ b/lib/ash_authentication/token_revocation/transformer.ex @@ -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 diff --git a/lib/ash_authentication/transformer.ex b/lib/ash_authentication/transformer.ex new file mode 100644 index 0000000..4fe7290 --- /dev/null +++ b/lib/ash_authentication/transformer.ex @@ -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 diff --git a/lib/ash_authentication/utils.ex b/lib/ash_authentication/utils.ex new file mode 100644 index 0000000..b06fb6b --- /dev/null +++ b/lib/ash_authentication/utils.ex @@ -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 diff --git a/lib/ash_authentication/validations.ex b/lib/ash_authentication/validations.ex new file mode 100644 index 0000000..01eae27 --- /dev/null +++ b/lib/ash_authentication/validations.ex @@ -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 diff --git a/lib/ash_authentication/validations/action.ex b/lib/ash_authentication/validations/action.ex new file mode 100644 index 0000000..e905eb1 --- /dev/null +++ b/lib/ash_authentication/validations/action.ex @@ -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 diff --git a/lib/ash_authentication/validations/attribute.ex b/lib/ash_authentication/validations/attribute.ex new file mode 100644 index 0000000..bb2a1e7 --- /dev/null +++ b/lib/ash_authentication/validations/attribute.ex @@ -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 diff --git a/mix.exs b/mix.exs index 0615dc1..d1e3f3d 100644 --- a/mix.exs +++ b/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 diff --git a/mix.lock b/mix.lock index 1e92b07..f2a42db 100644 --- a/mix.lock +++ b/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"}, } diff --git a/priv/repo/migrations/20221002235524_install_2_extensions.exs b/priv/repo/migrations/20221002235524_install_2_extensions.exs new file mode 100644 index 0000000..571c88e --- /dev/null +++ b/priv/repo/migrations/20221002235524_install_2_extensions.exs @@ -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 \ No newline at end of file diff --git a/priv/repo/migrations/20221002235526_migrate_resources1.exs b/priv/repo/migrations/20221002235526_migrate_resources1.exs new file mode 100644 index 0000000..6271d98 --- /dev/null +++ b/priv/repo/migrations/20221002235526_migrate_resources1.exs @@ -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 \ No newline at end of file diff --git a/priv/repo/migrations/20221020042559_add_token_revocation_table.exs b/priv/repo/migrations/20221020042559_add_token_revocation_table.exs new file mode 100644 index 0000000..e476790 --- /dev/null +++ b/priv/repo/migrations/20221020042559_add_token_revocation_table.exs @@ -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 \ No newline at end of file diff --git a/priv/resource_snapshots/extensions.json b/priv/resource_snapshots/extensions.json new file mode 100644 index 0000000..bd5215b --- /dev/null +++ b/priv/resource_snapshots/extensions.json @@ -0,0 +1,6 @@ +{ + "installed": [ + "uuid-ossp", + "citext" + ] +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/token_revocations/20221020042559.json b/priv/resource_snapshots/repo/token_revocations/20221020042559.json new file mode 100644 index 0000000..642e431 --- /dev/null +++ b/priv/resource_snapshots/repo/token_revocations/20221020042559.json @@ -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" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/user_with_username/20221002235526.json b/priv/resource_snapshots/repo/user_with_username/20221002235526.json new file mode 100644 index 0000000..07a45d6 --- /dev/null +++ b/priv/resource_snapshots/repo/user_with_username/20221002235526.json @@ -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" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/user_with_username/20221020042559.json b/priv/resource_snapshots/repo/user_with_username/20221020042559.json new file mode 100644 index 0000000..19c8214 --- /dev/null +++ b/priv/resource_snapshots/repo/user_with_username/20221020042559.json @@ -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" +} \ No newline at end of file diff --git a/test/ash_authentication/jwt/config_test.exs b/test/ash_authentication/jwt/config_test.exs new file mode 100644 index 0000000..a580491 --- /dev/null +++ b/test/ash_authentication/jwt/config_test.exs @@ -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 diff --git a/test/ash_authentication/jwt_test.exs b/test/ash_authentication/jwt_test.exs new file mode 100644 index 0000000..77096d0 --- /dev/null +++ b/test/ash_authentication/jwt_test.exs @@ -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 diff --git a/test/ash_authentication/password_authentication/action_test.exs b/test/ash_authentication/password_authentication/action_test.exs new file mode 100644 index 0000000..8b91a88 --- /dev/null +++ b/test/ash_authentication/password_authentication/action_test.exs @@ -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 diff --git a/test/ash_authentication/password_authentication/identity_test.exs b/test/ash_authentication/password_authentication/identity_test.exs new file mode 100644 index 0000000..2a78777 --- /dev/null +++ b/test/ash_authentication/password_authentication/identity_test.exs @@ -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 diff --git a/test/ash_authentication/token_revocation_test.exs b/test/ash_authentication/token_revocation_test.exs new file mode 100644 index 0000000..f1896c6 --- /dev/null +++ b/test/ash_authentication/token_revocation_test.exs @@ -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 diff --git a/test/ash_authentication_test.exs b/test/ash_authentication_test.exs index 150dedb..331f357 100644 --- a/test/ash_authentication_test.exs +++ b/test/ash_authentication_test.exs @@ -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 diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..952a6f9 --- /dev/null +++ b/test/support/data_case.ex @@ -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 diff --git a/test/support/example.ex b/test/support/example.ex new file mode 100644 index 0000000..cca718d --- /dev/null +++ b/test/support/example.ex @@ -0,0 +1,8 @@ +defmodule Example do + @moduledoc false + use Ash.Api, otp_app: :ash_authentication + + resources do + registry Example.Registry + end +end diff --git a/test/support/example/auth_plug.ex b/test/support/example/auth_plug.ex new file mode 100644 index 0000000..128dd13 --- /dev/null +++ b/test/support/example/auth_plug.ex @@ -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 diff --git a/test/support/example/registry.ex b/test/support/example/registry.ex new file mode 100644 index 0000000..9532cbc --- /dev/null +++ b/test/support/example/registry.ex @@ -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 diff --git a/test/support/example/repo.ex b/test/support/example/repo.ex new file mode 100644 index 0000000..afe8962 --- /dev/null +++ b/test/support/example/repo.ex @@ -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 diff --git a/test/support/example/token_revocation.ex b/test/support/example/token_revocation.ex new file mode 100644 index 0000000..8fc672f --- /dev/null +++ b/test/support/example/token_revocation.ex @@ -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 diff --git a/test/support/example/user_with_username.ex b/test/support/example/user_with_username.ex new file mode 100644 index 0000000..00a8dbc --- /dev/null +++ b/test/support/example/user_with_username.ex @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..3be0501 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,2 @@ -ExUnit.start() +Mimic.copy(AshAuthentication.TokenRevocation) +ExUnit.start(capture_log: true)