From ba1b6ee74875a9ffbf1ec3cdb20c00add91ee363 Mon Sep 17 00:00:00 2001 From: Daniel Berkompas Date: Mon, 11 Dec 2017 09:45:58 -0800 Subject: [PATCH 1/4] Add Exchange This exchange module will be used to exchange one set of credentials for another, such as "user/password => token". --- lib/authentication/authentication.ex | 28 ++++----- lib/authentication/store.ex | 6 +- lib/authentication/stores/ecto_store.ex | 5 +- lib/exchange/exchange.ex | 51 ++++++++++++++++ lib/exchange/store.ex | 9 +++ test/authentication/authentication_test.exs | 46 +++++++++----- test/exchange/exchange_test.exs | 68 +++++++++++++++++++++ 7 files changed, 179 insertions(+), 34 deletions(-) create mode 100644 lib/exchange/exchange.ex create mode 100644 lib/exchange/store.ex create mode 100644 test/exchange/exchange_test.exs diff --git a/lib/authentication/authentication.ex b/lib/authentication/authentication.ex index ec4874b..a65a46f 100644 --- a/lib/authentication/authentication.ex +++ b/lib/authentication/authentication.ex @@ -1,22 +1,22 @@ defmodule Authority.Authentication do @type id :: any - @type credential :: any + @type credential :: {id, any} | any + @type opts :: Keyword.t() - @callback authenticate(credential) :: {:ok, any} | {:error, term} - @callback authenticate(id, credential) :: {:ok, any} | {:error, term} + @callback authenticate(credential, opts) :: {:ok, any} | {:error, term} @doc false - def authenticate(%{store: store}, credential) do - with {:ok, identity} <- store.identify(credential), - :ok <- store.validate(credential, identity) do + def authenticate(%{store: store}, {identifier, credential}, opts) do + with {:ok, identity} <- store.identify(identifier, opts), + :ok <- store.validate(credential, identity, opts) do {:ok, identity} end end @doc false - def authenticate(%{store: store}, identifier, credential) do - with {:ok, identity} <- store.identify(identifier), - :ok <- store.validate(credential, identity) do + def authenticate(%{store: store}, credential, opts) do + with {:ok, identity} <- store.identify(credential, opts), + :ok <- store.validate(credential, identity, opts) do {:ok, identity} end end @@ -24,14 +24,14 @@ defmodule Authority.Authentication do defmacro __using__(config) do quote do @behaviour Authority.Authentication - @config Enum.into(unquote(config), %{}) + @__authentication__ Enum.into(unquote(config), %{}) - def authenticate(credential) do - Authority.Authentication.authenticate(@config, credential) + def authenticate(credential, opts \\ []) do + Authority.Authentication.authenticate(@__authentication__, credential, opts) end - def authenticate(identifier, credential) do - Authority.Authentication.authenticate(@config, identifier, credential) + def __authentication__ do + @__authentication__ end defoverridable Authority.Authentication diff --git a/lib/authentication/store.ex b/lib/authentication/store.ex index 405d8ec..b1f13fd 100644 --- a/lib/authentication/store.ex +++ b/lib/authentication/store.ex @@ -1,7 +1,9 @@ defmodule Authority.Authentication.Store do + @type opts :: Keyword.t() + @type id :: any @type identity :: any @type credential :: any - @callback identify(any) :: {:ok, identity} | {:error, term} - @callback validate(credential, identity) :: :ok | {:error, term} + @callback identify(id, opts) :: {:ok, identity} | {:error, term} + @callback validate(credential, identity, opts) :: :ok | {:error, term} end \ No newline at end of file diff --git a/lib/authentication/stores/ecto_store.ex b/lib/authentication/stores/ecto_store.ex index 59d3879..597d010 100644 --- a/lib/authentication/stores/ecto_store.ex +++ b/lib/authentication/stores/ecto_store.ex @@ -24,7 +24,7 @@ if Code.ensure_loaded?(Ecto) do end end - def identify(config, identifier) do + def identify(config, identifier, _opts) do identity = do_identify( config[:repo], @@ -59,7 +59,8 @@ if Code.ensure_loaded?(Ecto) do def validate( %{credential_field: field, credential_type: type, hash_algorithm: algorithm}, credential, - identity + identity, + _opts ) do expected = identity[field] diff --git a/lib/exchange/exchange.ex b/lib/exchange/exchange.ex new file mode 100644 index 0000000..634a743 --- /dev/null +++ b/lib/exchange/exchange.ex @@ -0,0 +1,51 @@ +defmodule Authority.Exchange do + # MyModule.exchange("user@email.com", for: MyApp.Token, context: :recovery) + # MyModule.Exchange.exchange(OAuthCode{provider: :facebook, code: "..."}, for: MyApp.Token, context: :identity) + # MyModule.Exchange.exchange({"user@email.com", "password"}, for: MyApp.Token, context: :identity) + + alias Authority.Authentication + + @type opts :: Keyword.t() + + @callback exchange(Authentication.credential()) :: + {:ok, Authentication.credential()} + | {:error, term} + + @callback exchange(Authentication.credential(), opts) :: + {:ok, Authentication.credential()} + | {:error, term} + + @doc false + def exchange(%{store: store, authentication: authentication}, credential, opts) do + with {:ok, identity} <- authentication.authenticate(credential, opts) do + store.exchange(identity, opts) + end + end + + @doc false + def exchange( + %{store: store, authentication: authentication}, + identifier, + credential, + opts + ) do + with {:ok, identity} <- authentication.authenticate(identifier, credential, opts) do + store.exchange(identity, opts) + end + end + + defmacro __using__(config) do + quote do + @behaviour Authority.Exchange + @__exchange__ Enum.into(unquote(config), %{}) + + def exchange(credential, opts \\ []) do + Authority.Exchange.exchange(@__exchange__, credential, opts) + end + + def __exchange__ do + @__exchange__ + end + end + end +end \ No newline at end of file diff --git a/lib/exchange/store.ex b/lib/exchange/store.ex new file mode 100644 index 0000000..50c1a63 --- /dev/null +++ b/lib/exchange/store.ex @@ -0,0 +1,9 @@ +defmodule Authority.Exchange.Store do + alias Authority.Authentication + + @type opts :: Keyword.t() + + @callback exchange(Authentication.identity(), opts) :: + {:ok, Authentication.credential()} + | {:error, term} +end \ No newline at end of file diff --git a/test/authentication/authentication_test.exs b/test/authentication/authentication_test.exs index 048068a..e3ee9f9 100644 --- a/test/authentication/authentication_test.exs +++ b/test/authentication/authentication_test.exs @@ -1,8 +1,6 @@ defmodule Authority.AuthenticationTest do use ExUnit.Case - import Authority.Authentication - defmodule Email do defstruct [:email] end @@ -22,23 +20,23 @@ defmodule Authority.AuthenticationTest do defmodule TestStore do @behaviour Authority.Authentication.Store - def identify(%Email{email: "existing@user.com"}) do + def identify(%Email{email: "existing@user.com"}, _opts) do {:ok, %User{password: "password"}} end - def identify(%Email{}) do + def identify(%Email{}, _opts) do {:error, :invalid_email} end - def identify(%Key{key: key}) when key in ["valid", "expired"] do + def identify(%Key{key: key}, _opts) when key in ["valid", "expired"] do {:ok, %User{}} end - def identify(%Key{}) do + def identify(%Key{}, _opts) do {:error, :invalid_key} end - def validate(%Password{password: password}, user) do + def validate(%Password{password: password}, user, _opts) do if password == user.password do :ok else @@ -46,38 +44,54 @@ defmodule Authority.AuthenticationTest do end end - def validate(%Key{key: "valid"}, _user), do: :ok - def validate(%Key{key: "expired"}, _user), do: {:error, :key_expired} + def validate(%Key{key: "valid"}, _user, _opts), do: :ok + def validate(%Key{key: "expired"}, _user, _opts), do: {:error, :key_expired} + + # Validate email, but only for the recovery context + def validate(%Email{email: "existing@user.com"}, _user, opts) do + if opts[:context] == :recovery do + :ok + else + {:error, :credential_required} + end + end end - @config %{store: TestStore} + defmodule TestAuth do + use Authority.Authentication, store: TestStore + end describe ".authenticate/2" do test "returns error if credential does not exist" do - assert {:error, :invalid_key} = authenticate(@config, ~K[nonexistent]) + assert {:error, :invalid_key} = TestAuth.authenticate(~K[nonexistent]) end test "returns error if credential exists, but is invalid" do - assert {:error, :key_expired} = authenticate(@config, ~K[expired]) + assert {:error, :key_expired} = TestAuth.authenticate(~K[expired]) end test "returns identity if credential is valid" do - assert {:ok, %User{}} = authenticate(@config, ~K[valid]) + assert {:ok, %User{}} = TestAuth.authenticate(~K[valid]) + end + + test "returns identity for email if context is :recovery" do + assert {:ok, %User{}} = TestAuth.authenticate(~E[existing@user.com], context: :recovery) + assert {:error, :credential_required} = TestAuth.authenticate(~E[existing@user.com]) end end describe ".authenticate/3" do test "returns error if identifier is invalid" do assert {:error, :invalid_email} = - authenticate(@config, ~E[nonexistent@user.com], ~P[password]) + TestAuth.authenticate({~E[nonexistent@user.com], ~P[password]}) end test "returns error if identifier is valid but credential is invalid" do - assert {:error, _} = authenticate(@config, ~E[existing@user.com], ~P[invalid]) + assert {:error, _} = TestAuth.authenticate({~E[existing@user.com], ~P[invalid]}) end test "returns identity if identifier and credential are valid" do - assert {:ok, %User{}} = authenticate(@config, ~E[existing@user.com], ~P[password]) + assert {:ok, %User{}} = TestAuth.authenticate({~E[existing@user.com], ~P[password]}) end end diff --git a/test/exchange/exchange_test.exs b/test/exchange/exchange_test.exs new file mode 100644 index 0000000..8f2dcd8 --- /dev/null +++ b/test/exchange/exchange_test.exs @@ -0,0 +1,68 @@ +defmodule Authority.ExchangeTest do + use ExUnit.Case + + defmodule User do + defstruct [:user] + end + + defmodule Email do + defstruct [:email] + end + + defmodule Password do + defstruct [:password] + end + + defmodule Key do + defstruct [:context] + end + + defmodule TestStore do + @behaviour Authority.Exchange.Store + + def exchange(%User{}, opts) do + {:ok, struct(Key, %{context: opts[:context]})} + end + end + + defmodule TestAuth do + @behaviour Authority.Authentication + + def authenticate(%Email{}, %Password{}, _opts) do + {:ok, %User{}} + end + + def authenticate(_, _, _), do: {:error, :invalid_credentials} + + def authenticate(%Email{}, opts) do + if opts[:context] == :recovery do + {:ok, %User{}} + else + {:error, :credential_required} + end + end + + def authenticate(_, _), do: {:error, :invalid_credentials} + end + + defmodule TestExchange do + use Authority.Exchange, + store: TestStore, + authentication: TestAuth + end + + describe ".exchange/2" do + test "exchanges an email for a key in the :recovery context" do + assert {:ok, %Key{}} = TestExchange.exchange(~E[my@email.com], context: :recovery) + + assert {:error, :credential_required} = + TestExchange.exchange(~E[my@email.com], context: :identity) + + assert {:error, :credential_required} = TestExchange.exchange(~E[my@email.com]) + end + end + + defp sigil_E(email, _) do + %Email{email: email} + end +end \ No newline at end of file From 29da8f43f031e1d0f55d20585d55cf82e614cd99 Mon Sep 17 00:00:00 2001 From: Daniel Berkompas Date: Mon, 11 Dec 2017 09:45:58 -0800 Subject: [PATCH 2/4] Support Exchange in EctoStore This will massively simplify how you integrate `Exchange` if you are using `EctoStore`. --- lib/authentication/stores/ecto_store.ex | 84 ------- lib/stores/ecto_store.ex | 306 ++++++++++++++++++++++++ mix.exs | 7 +- mix.lock | 8 + test/stores/ecto_store_test.exs | 174 ++++++++++++++ 5 files changed, 492 insertions(+), 87 deletions(-) delete mode 100644 lib/authentication/stores/ecto_store.ex create mode 100644 lib/stores/ecto_store.ex create mode 100644 mix.lock create mode 100644 test/stores/ecto_store_test.exs diff --git a/lib/authentication/stores/ecto_store.ex b/lib/authentication/stores/ecto_store.ex deleted file mode 100644 index 597d010..0000000 --- a/lib/authentication/stores/ecto_store.ex +++ /dev/null @@ -1,84 +0,0 @@ -if Code.ensure_loaded?(Ecto) do - defmodule Authority.Authentication.EctoStore do - defmacro __using__(config) do - quote do - @opts unquote(opts) - @config Enum.into(unquote(config), %{}) - - @store Authority.Authentication.EctoStore - @behaviour Authority.Authentication.Store - - def identify(identifier) do - @store.identify(@config, identifier) - end - - def validate(credential, identity) do - @store.validate(@config, credential, identity) - end - - def config do - @config - end - - defoverridable Authority.Authentication.Store - end - end - - def identify(config, identifier, _opts) do - identity = - do_identify( - config[:repo], - config[:schema], - config[:identity_fields] || config[:identity_field], - identifier - ) - - if identity do - {:ok, identity} - else - {:error, :"invalid_#{field}"} - end - end - - defp do_identify(repo, schema, field, identifier) when is_atom(field) do - repo.get_by(schema, [{field, identifier}]) - end - - def do_identify(repo, schema, [field | _] = fields, identifier) - when is_atom(field) do - import Ecto.Query - - query = - Enum.reduce(fields, schema, fn field, query -> - or_where(schema, [{^field, ^identifier}]) - end) - - repo.one(query) - end - - def validate( - %{credential_field: field, credential_type: type, hash_algorithm: algorithm}, - credential, - identity, - _opts - ) do - expected = identity[field] - - if equal?(credential, expected, type, algorithm) do - :ok - else - {:error, :"invalid_#{field}"} - end - end - - defp equal?(credential, expected, :string, _) do - credential == expected - end - - if Code.ensure_loaded?(Comeonin.Bcrypt) do - defp equal?(credential, expected, :hash, :bcrypt) do - Comeonin.Bcrypt.checkpw(expected, credential) - end - end - end -end \ No newline at end of file diff --git a/lib/stores/ecto_store.ex b/lib/stores/ecto_store.ex new file mode 100644 index 0000000..1973b6e --- /dev/null +++ b/lib/stores/ecto_store.ex @@ -0,0 +1,306 @@ +if Code.ensure_loaded?(Ecto) do + # use Authority.EctoStore, + # repo: MyApp.Repo, + # authentication: %{ + # schema: MyApp.Accounts.User, + # identity_field: :email, + # credential_field: :password, + # credential_type: :hash, + # hash_algorithm: :bcrypt + # }, + # exchange: %{ + # schema: MyApp.Accounts.Token, + # identity_assoc: :user, + # token_field: :token, + # token_type: :uuid, + # expiry_field: :expires_at, + # context_field: :context, + # contexts: %{ + # identity: %{ + # default: true, + # expires_in_seconds: 60, + # }, + # recovery: %{ + # expires_in_seconds: 60 + # } + # } + # } + defmodule Authority.EctoStore do + defmodule ConfigError do + defexception [:message] + end + + defmacro __using__(config) do + {compile_config, _} = Code.eval_quoted(config, [], __CALLER__) + validate_config!(compile_config) + opts = default_opts(compile_config) + + quote do + @config Enum.into(unquote(config), %{}) + @store Authority.EctoStore + + if @config[:authentication] do + @behaviour Authority.Authentication.Store + + def identify(identifier, opts \\ unquote(opts)) do + @store.identify(@config, identifier, Enum.into(opts, %{})) + end + + def validate(credential, identity, opts \\ unquote(opts)) do + @store.validate(@config, credential, identity, Enum.into(opts, %{})) + end + + defoverridable Authority.Authentication.Store + end + + if @config[:exchange] do + @behaviour Authority.Exchange.Store + + def exchange(identity, opts \\ []) do + @store.exchange(@config, identity, Enum.into(opts, %{})) + end + + defoverridable Authority.Exchange.Store + end + + def config do + @config + end + end + end + + @doc false + def exchange(%{repo: repo, exchange: exchange}, identity, %{context: context}) do + expires_in_seconds = exchange[:contexts][context][:expires_in_seconds] + + fields = %{ + exchange[:identity_assoc] => identity, + exchange[:expiry_field] => generate_expires_at(expires_in_seconds), + exchange[:context_field] => context, + exchange[:token_field] => generate_token(exchange[:token_type]) + } + + exchange[:schema] + |> struct(fields) + |> repo.insert() + end + + defp generate_token(:uuid) do + Ecto.UUID.generate() + end + + defp generate_expires_at(seconds) do + DateTime.utc_now() + |> DateTime.to_unix() + |> Kernel.+(seconds) + |> DateTime.from_unix!() + end + + # Identify exchange schema + @doc false + def identify( + %{ + repo: repo, + exchange: %{ + schema: schema, + token_field: token_field, + identity_assoc: identity_assoc + } + }, + %{__struct__: schema} = token, + _opts + ) do + token = + schema + |> repo.get_by([{token_field, Map.get(token, token_field)}]) + |> repo.preload(identity_assoc) + + if token do + {:ok, Map.get(token, identity_assoc)} + else + {:error, :invalid_token} + end + end + + # Identify username, email + @doc false + def identify(%{repo: repo, authentication: %{schema: schema} = auth}, identifier, _opts) do + identity = + do_identify( + repo, + schema, + auth[:identity_fields] || auth[:identity_field], + identifier + ) + + if identity do + {:ok, identity} + else + fields = auth[:identity_fields] || [auth[:identity_field]] + + fields = + fields + |> Enum.map(&to_string/1) + |> Enum.join("_or_") + + {:error, :"invalid_#{fields}"} + end + end + + defp do_identify(repo, schema, field, identifier) when is_atom(field) do + repo.get_by(schema, [{field, identifier}]) + end + + defp do_identify(repo, schema, [field | _] = fields, identifier) + when is_atom(field) do + import Ecto.Query + + query = + Enum.reduce(fields, schema, fn field, query -> + or_where(query, ^[{field, identifier}]) + end) + + repo.one(query) + end + + @doc false + def validate( + %{ + exchange: %{ + schema: schema, + context_field: context_field, + expiry_field: expiry_field + } + }, + %{__struct__: schema} = token, + _identity, + %{context: context} + ) do + token_context = Map.get(token, context_field) + expires_at = Map.get(token, expiry_field) + + cond do + token_context != context -> + {:error, :token_invalid_for_context} + + DateTime.compare(DateTime.utc_now(), expires_at) in [:gt, :eq] -> + {:error, :token_expired} + + true -> + :ok + end + end + + def validate( + %{authentication: %{credential_field: field, credential_type: type} = auth}, + credential, + identity, + _opts + ) do + expected = Map.get(identity, field) + + if equal?(credential, expected, type, auth[:hash_algorithm]) do + :ok + else + {:error, :"invalid_#{field}"} + end + end + + defp equal?(credential, expected, :string, _) do + credential == expected + end + + if Code.ensure_loaded?(Comeonin.Bcrypt) do + defp equal?(credential, expected, :hash, :bcrypt) do + Comeonin.Bcrypt.checkpw(credential, expected) + end + end + + defp validate_config!(config) do + unless config[:repo], do: raise(ConfigError, ":repo module not set") + validate_authentication!(config[:authentication]) + validate_exchange!(config[:exchange]) + end + + defp validate_authentication!(config) when is_map(config) do + fields = [ + :schema, + :identity_field, + :credential_field, + :credential_type + ] + + for field <- fields do + unless config[field], + do: raise(ConfigError, "no #{inspect(field)} set for :authentication") + end + + if config[:credential_type] == :hash && config[:hash_algorithm] != :bcrypt do + raise ConfigError, """ + :hash_algorithm is required for :authentication when :credential_type == :hash. + Valid algorithms: :bcrypt + """ + end + end + + defp validate_authentication!(_other), do: :noop + + defp validate_exchange!(config) when is_map(config) do + fields = [ + :schema, + :identity_assoc, + :token_field, + :token_type, + :expiry_field, + :context_field, + :contexts + ] + + for field <- fields do + unless config[field], do: raise(ConfigError, "no #{inspect(field)} set for :exchange") + end + + for {context, settings} <- config[:contexts] do + unless is_integer(settings[:expires_in_seconds]), + do: + raise( + ConfigError, + "invalid :expires_in_seconds in context #{inspect(context)} for :exchange" + ) + end + + default_context = + Enum.find(config[:contexts], fn {_context, settings} -> settings[:default] == true end) + + unless default_context do + raise ConfigError, """ + no default context specified for :exchange. + + contexts: %{ + my_context: %{ + default: true, # add this line + expires_in_seconds: 100 + } + } + """ + end + end + + defp validate_exchange!(_other), do: :noop + + defp default_opts(config) do + if config[:authentication] && config[:exchange] do + default_context = + Enum.find_value(config[:exchange][:contexts], fn {context, settings} -> + if settings[:default] do + context + end + end) + + [context: default_context] + else + [] + end + end + end +end \ No newline at end of file diff --git a/mix.exs b/mix.exs index e660c49..01a53fd 100644 --- a/mix.exs +++ b/mix.exs @@ -21,8 +21,9 @@ defmodule Authority.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - # {:dep_from_hexpm, "~> 0.3.0"}, - # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, + {:ecto, ">= 0.0.0", only: [:dev, :test]}, + {:comeonin, ">= 0.0.0", only: [:dev, :test]}, + {:bcrypt_elixir, ">= 0.0.0", only: [:dev, :test]} ] end -end +end \ No newline at end of file diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..7f671be --- /dev/null +++ b/mix.lock @@ -0,0 +1,8 @@ +%{ + "bcrypt_elixir": {:hex, :bcrypt_elixir, "1.0.5", "cca70b5c8d9a98a0151c2d2796c728719c9c4b3f8bd2de015758ef577ee5141e", [], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, + "comeonin": {:hex, :comeonin, "4.0.3", "4e257dcb748ed1ca2651b7ba24fdbd1bd24efd12482accf8079141e3fda23a10", [], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm"}, + "decimal": {:hex, :decimal, "1.4.1", "ad9e501edf7322f122f7fc151cce7c2a0c9ada96f2b0155b8a09a795c2029770", [], [], "hexpm"}, + "ecto": {:hex, :ecto, "2.2.7", "2074106ff4a5cd9cb2b54b12ca087c4b659ddb3f6b50be4562883c1d763fb031", [], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, + "elixir_make": {:hex, :elixir_make, "0.4.0", "992f38fabe705bb45821a728f20914c554b276838433349d4f2341f7a687cddf", [], [], "hexpm"}, + "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [], [], "hexpm"}, +} diff --git a/test/stores/ecto_store_test.exs b/test/stores/ecto_store_test.exs new file mode 100644 index 0000000..8c87932 --- /dev/null +++ b/test/stores/ecto_store_test.exs @@ -0,0 +1,174 @@ +defmodule Authority.EctoStoreTest do + use ExUnit.Case + + @password_hash "$2b$12$V2Pogh16L1BbvDOFS4oIbuavOq91u6XFD1mhFL.kaeAO8Nl4pNGdy" + @future DateTime.from_naive!(~N[3000-01-01 00:00:00], "Etc/UTC") + @past DateTime.from_naive!(~N[1000-01-01 00:00:00], "Etc/UTC") + + defmodule User do + use Ecto.Schema + + embedded_schema do + field(:email, :string) + field(:encrypted_password, :string) + end + end + + defmodule Token do + use Ecto.Schema + + embedded_schema do + belongs_to(:user, User) + + field(:context, :string) + field(:token, Ecto.UUID) + field(:expires_at, :utc_datetime) + end + end + + defmodule Repo do + @password_hash "$2b$12$V2Pogh16L1BbvDOFS4oIbuavOq91u6XFD1mhFL.kaeAO8Nl4pNGdy" + @user %User{email: "existing@user.com", encrypted_password: @password_hash} + @future DateTime.from_naive!(~N[3000-01-01 00:00:00], "Etc/UTC") + @past DateTime.from_naive!(~N[1000-01-01 00:00:00], "Etc/UTC") + + def insert(struct) do + {:ok, struct} + end + + def get_by(User, conditions) do + case conditions[:email] do + "existing@user.com" -> + %User{email: "existing@user.com", encrypted_password: @password_hash} + + _ -> + nil + end + end + + def get_by(Token, token: "valid_identity") do + %Token{ + context: :identity, + user: @user, + token: Ecto.UUID.generate(), + expires_at: @future + } + end + + def get_by(Token, token: "expired_identity") do + %Token{ + context: :identity, + user: @user, + token: Ecto.UUID.generate(), + expires_at: @past + } + end + + def get_by(Token, token: "valid_recovery") do + %Token{ + context: :recovery, + user: @user, + token: Ecto.UUID.generate(), + expires_at: @future + } + end + + def preload(struct, _keys) do + struct + end + end + + defmodule Store do + use Authority.EctoStore, + repo: Repo, + authentication: %{ + schema: User, + identity_field: :email, + credential_field: :encrypted_password, + credential_type: :hash, + hash_algorithm: :bcrypt + }, + exchange: %{ + schema: Token, + identity_assoc: :user, + token_field: :token, + token_type: :uuid, + context_field: :context, + expiry_field: :expires_at, + contexts: %{ + identity: %{ + default: true, + expires_in_seconds: 60 + }, + recovery: %{ + expires_in_seconds: 60 + } + } + } + end + + describe ".identify/2" do + test "returns error if email is invalid" do + assert {:error, :invalid_email} = Store.identify("invalid@email.com") + end + + test "returns identity if email exists" do + assert {:ok, %User{}} = Store.identify("existing@user.com") + end + + test "returns identity if token exists" do + for token <- ~w[valid_recovery valid_identity expired_identity] do + assert {:ok, %User{}} = Store.identify(%Token{token: token}) + end + end + end + + describe ".validate/3" do + test "returns error if password is invalid" do + assert {:error, :invalid_encrypted_password} = + Store.validate("invalid", %User{encrypted_password: @password_hash}) + end + + test "returns error if token has expired" do + assert {:error, :token_expired} = + Store.validate(%Token{context: :identity, expires_at: @past}, %User{}) + end + + test "returns error if token is invalid for context" do + assert {:error, :token_invalid_for_context} = + Store.validate(%Token{context: :recovery, expires_at: @future}, %User{}) + + assert {:error, :token_invalid_for_context} = + Store.validate( + %Token{context: :identity, expires_at: @future}, + %User{}, + context: :recovery + ) + end + + test "returns ok if password is valid" do + assert :ok == Store.validate("password", %User{encrypted_password: @password_hash}) + end + + test "returns ok if token is valid" do + assert :ok == Store.validate(%Token{context: :identity, expires_at: @future}, %User{}) + + assert :ok == + Store.validate( + %Token{context: :recovery, expires_at: @future}, + %User{}, + context: :recovery + ) + end + end + + describe ".exchange/2" do + test "generates a token" do + assert {:ok, token} = Store.exchange(%User{id: 2}, context: :recovery) + assert token.context == :recovery + assert token.token + assert token.user == %User{id: 2} + assert DateTime.compare(DateTime.utc_now(), token.expires_at) == :lt + end + end +end \ No newline at end of file From 526b30258eb1bec3aef589e4d7fac82ca4cea5b7 Mon Sep 17 00:00:00 2001 From: Daniel Berkompas Date: Mon, 11 Dec 2017 14:46:36 -0800 Subject: [PATCH 3/4] Add support for exchanging email in recovery context You can add `skip_validation` patterns which will exclude a credential from validations. The patterns can either be a regex or an Elixir pattern like a struct. Also, they're limited to a specific context. --- lib/stores/ecto_store.ex | 44 +++++++++++++++++++++++++++------ test/stores/ecto_store_test.exs | 9 ++++++- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/lib/stores/ecto_store.ex b/lib/stores/ecto_store.ex index 1973b6e..3856116 100644 --- a/lib/stores/ecto_store.ex +++ b/lib/stores/ecto_store.ex @@ -21,7 +21,8 @@ if Code.ensure_loaded?(Ecto) do # expires_in_seconds: 60, # }, # recovery: %{ - # expires_in_seconds: 60 + # expires_in_seconds: 60, + # skip_validation: [~r/@/] # allow bare email addresses to be exchanged for tokens # } # } # } @@ -191,12 +192,36 @@ if Code.ensure_loaded?(Ecto) do end end - def validate( - %{authentication: %{credential_field: field, credential_type: type} = auth}, - credential, - identity, - _opts - ) do + def validate(%{exchange: %{contexts: contexts}} = config, credential, identity, %{ + context: context + }) do + settings = contexts[context] + skip_patterns = settings[:skip_validation] + + if is_list(skip_patterns) && Enum.any?(skip_patterns, &pattern_match?(&1, credential)) do + :ok + else + do_validate(config, credential, identity) + end + end + + def validate(config, credential, identity, _opts) do + do_validate(config, credential, identity) + end + + defp pattern_match?(%Regex{} = pattern, target) when is_binary(target) do + Regex.match?(pattern, target) + end + + defp pattern_match?(pattern, target) do + Kernel.match?(^pattern, target) + end + + defp do_validate( + %{authentication: %{credential_field: field, credential_type: type} = auth}, + credential, + identity + ) do expected = Map.get(identity, field) if equal?(credential, expected, type, auth[:hash_algorithm]) do @@ -211,11 +236,14 @@ if Code.ensure_loaded?(Ecto) do end if Code.ensure_loaded?(Comeonin.Bcrypt) do - defp equal?(credential, expected, :hash, :bcrypt) do + defp equal?(credential, expected, :hash, :bcrypt) + when is_binary(credential) and is_binary(expected) do Comeonin.Bcrypt.checkpw(credential, expected) end end + defp equal?(_credential, _expected, _, _), do: false + defp validate_config!(config) do unless config[:repo], do: raise(ConfigError, ":repo module not set") validate_authentication!(config[:authentication]) diff --git a/test/stores/ecto_store_test.exs b/test/stores/ecto_store_test.exs index 8c87932..3918095 100644 --- a/test/stores/ecto_store_test.exs +++ b/test/stores/ecto_store_test.exs @@ -101,7 +101,8 @@ defmodule Authority.EctoStoreTest do expires_in_seconds: 60 }, recovery: %{ - expires_in_seconds: 60 + expires_in_seconds: 60, + skip_validation: [~r/@/] } } } @@ -146,6 +147,12 @@ defmodule Authority.EctoStoreTest do ) end + # This allows us to exchange email addresses for recovery tokens + test "returns ok if email is valid and context is recovery" do + assert :ok == Store.validate("existing@email.com", %User{}, context: :recovery) + assert {:error, _} = Store.validate("existing@email.com", %User{}) + end + test "returns ok if password is valid" do assert :ok == Store.validate("password", %User{encrypted_password: @password_hash}) end From da71db67b4191b654b9c80dd5ade8377d8dfc99f Mon Sep 17 00:00:00 2001 From: Daniel Berkompas Date: Mon, 11 Dec 2017 14:56:11 -0800 Subject: [PATCH 4/4] Rename attributes to reduce likelihood of collisions --- lib/stores/ecto_store.ex | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/stores/ecto_store.ex b/lib/stores/ecto_store.ex index 3856116..6a8a753 100644 --- a/lib/stores/ecto_store.ex +++ b/lib/stores/ecto_store.ex @@ -37,35 +37,38 @@ if Code.ensure_loaded?(Ecto) do opts = default_opts(compile_config) quote do - @config Enum.into(unquote(config), %{}) + @__ecto_store__ Enum.into(unquote(config), %{}) @store Authority.EctoStore - if @config[:authentication] do + if @__ecto_store__[:authentication] do @behaviour Authority.Authentication.Store + @impl true def identify(identifier, opts \\ unquote(opts)) do - @store.identify(@config, identifier, Enum.into(opts, %{})) + @store.identify(@__ecto_store__, identifier, Enum.into(opts, %{})) end + @impl true def validate(credential, identity, opts \\ unquote(opts)) do - @store.validate(@config, credential, identity, Enum.into(opts, %{})) + @store.validate(@__ecto_store__, credential, identity, Enum.into(opts, %{})) end defoverridable Authority.Authentication.Store end - if @config[:exchange] do + if @__ecto_store__[:exchange] do @behaviour Authority.Exchange.Store + @impl true def exchange(identity, opts \\ []) do - @store.exchange(@config, identity, Enum.into(opts, %{})) + @store.exchange(@__ecto_store__, identity, Enum.into(opts, %{})) end defoverridable Authority.Exchange.Store end - def config do - @config + def __ecto_store__ do + @__ecto_store__ end end end