diff --git a/lib/authorization.ex b/lib/authorization.ex index 9d772d0..dc9accb 100644 --- a/lib/authorization.ex +++ b/lib/authorization.ex @@ -14,6 +14,8 @@ defmodule Rajska.Authorization do @callback get_current_user(context) :: current_user + @callback get_ip(context) :: String.t() + @callback get_user_role(current_user) :: role @callback not_scoped_roles() :: list(role) @@ -29,6 +31,7 @@ defmodule Rajska.Authorization do @callback context_user_authorized?(context, scoped_struct, rule) :: boolean() @optional_callbacks get_current_user: 1, + get_ip: 1, get_user_role: 1, not_scoped_roles: 0, role_authorized?: 2, diff --git a/lib/middlewares/rate_limiter.ex b/lib/middlewares/rate_limiter.ex new file mode 100644 index 0000000..866ed72 --- /dev/null +++ b/lib/middlewares/rate_limiter.ex @@ -0,0 +1,32 @@ +defmodule Rajska.RateLimiter do + @moduledoc """ + Rate limiter absinthe middleware. + """ + @behaviour Absinthe.Middleware + + alias Absinthe.Resolution + + def call(%Resolution{state: :resolved} = resolution, _config), do: resolution + + def call(%Resolution{} = resolution, config) do + scale_ms = Keyword.get(config, :scale_ms, 60_000) + limit = Keyword.get(config, :limit, 10) + identifier = get_identifier(resolution, config[:keys], config[:id]) + error_msg = Keyword.get(config, :error_msg, "Too many requests") + + case Hammer.check_rate("query:#{identifier}", scale_ms, limit) do + {:allow, _count} -> resolution + {:deny, _limit} -> Resolution.put_result(resolution, {:error, error_msg}) + end + end + + defp get_identifier(%Resolution{context: context}, nil, nil), + do: Rajska.apply_auth_mod(context, :get_ip, [context]) + + defp get_identifier(%Resolution{arguments: arguments}, keys, nil), + do: get_in(arguments, List.wrap(keys)) || raise "Invalid configuration in Rate Limiter. Key not found in arguments." + + defp get_identifier(%Resolution{}, nil, id), do: id + + defp get_identifier(%Resolution{}, _keys, _id), do: raise "Invalid configuration in Rate Limiter. If key is defined, then id must not be defined" +end diff --git a/lib/rajska.ex b/lib/rajska.ex index 17a4786..5768f55 100644 --- a/lib/rajska.ex +++ b/lib/rajska.ex @@ -81,6 +81,8 @@ defmodule Rajska do def get_current_user(%{current_user: current_user}), do: current_user + def get_ip(%{ip: ip}), do: ip + def get_user_role(%{role: role}), do: role def get_user_role(nil), do: nil diff --git a/mix.exs b/mix.exs index 7fbf4f5..d40e2cc 100644 --- a/mix.exs +++ b/mix.exs @@ -51,6 +51,8 @@ defmodule Rajska.MixProject do {:credo, "~> 1.1.0", only: [:dev, :test], runtime: false}, {:absinthe, "~> 1.4.0"}, {:excoveralls, "~> 0.11", only: :test}, + {:hammer, "~> 6.0", optional: true}, + {:mock, "~> 0.3.0", only: :test}, ] end diff --git a/mix.lock b/mix.lock index 4e57854..63aae51 100644 --- a/mix.lock +++ b/mix.lock @@ -7,14 +7,18 @@ "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, "excoveralls": {:hex, :excoveralls, "0.12.0", "50e17a1b116fdb7facc2fe127a94db246169f38d7627b391376a0bc418413ce1", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "hammer": {:hex, :hammer, "6.0.0", "72ec6fff10e9d63856968988a22ee04c4d6d5248071ddccfbda50aa6c455c1d7", [:mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm"}, "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, + "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, + "mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, + "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, } diff --git a/test/middlewares/rate_limiter_test.exs b/test/middlewares/rate_limiter_test.exs new file mode 100644 index 0000000..116c414 --- /dev/null +++ b/test/middlewares/rate_limiter_test.exs @@ -0,0 +1,128 @@ +defmodule Rajska.RateLimiterTest do + use ExUnit.Case, async: false + + import Mock + + defmodule Authorization do + use Rajska, + valid_roles: [:user, :admin], + super_role: :admin + end + + defmodule Schema do + use Absinthe.Schema + + def context(ctx), do: Map.put(ctx, :authorization, Authorization) + + input_object :keys_params do + field :id, :string + end + + query do + field :default_config, :string do + middleware Rajska.RateLimiter + resolve fn _, _ -> {:ok, "ok"} end + end + + field :scale_limit, :string do + middleware Rajska.RateLimiter, scale_ms: 30_000, limit: 5 + resolve fn _, _ -> {:ok, "ok"} end + end + + field :id, :string do + middleware Rajska.RateLimiter, id: :custom_id + resolve fn _, _ -> {:ok, "ok"} end + end + + field :key, :string do + arg :id, :string + + middleware Rajska.RateLimiter, keys: :id + resolve fn _, _ -> {:ok, "ok"} end + end + + field :keys, :string do + arg :params, :keys_params + middleware Rajska.RateLimiter, keys: [:params, :id] + resolve fn _, _ -> {:ok, "ok"} end + end + + field :id_and_key, :string do + middleware Rajska.RateLimiter, id: :id, keys: :keys + resolve fn _, _ -> {:ok, "ok"} end + end + + field :error_msg, :string do + middleware Rajska.RateLimiter, error_msg: "Rate limit exceeded" + resolve fn _, _ -> {:ok, "ok"} end + end + end + end + + @default_context [context: %{ip: "ip"}] + + setup_with_mocks([{Hammer, [], [check_rate: fn _a, _b, _c -> {:allow, 1} end]}]) do + :ok + end + + test "works with default configs" do + {:ok, _} = Absinthe.run(query(:default_config), __MODULE__.Schema, @default_context) + assert_called Hammer.check_rate("query:ip", 60_000, 10) + end + + test "accepts scale and limit configuration" do + {:ok, _} = Absinthe.run(query(:scale_limit), __MODULE__.Schema, @default_context) + assert_called Hammer.check_rate("query:ip", 30_000, 5) + end + + test "accepts id configuration" do + {:ok, _} = Absinthe.run(query(:id), __MODULE__.Schema, @default_context) + assert_called Hammer.check_rate("query:custom_id", 60_000, 10) + end + + test "accepts key configuration" do + {:ok, _} = Absinthe.run(query(:key, :id, "id_key"), __MODULE__.Schema, @default_context) + assert_called Hammer.check_rate("query:id_key", 60_000, 10) + end + + test "throws error if key is not present" do + assert_raise RuntimeError, ~r/Invalid configuration in Rate Limiter. Key not found in arguments./, fn -> + Absinthe.run(query(:key), __MODULE__.Schema, @default_context) + end + end + + test "accepts key configuration for nested parameters" do + {:ok, _} = Absinthe.run(query(:keys, :params, %{id: "id_key"}), __MODULE__.Schema, @default_context) + assert_called Hammer.check_rate("query:id_key", 60_000, 10) + end + + test "throws error when id and key are provided as configuration" do + assert_raise RuntimeError, ~r/Invalid configuration in Rate Limiter. If key is defined, then id must not be defined/, fn -> + Absinthe.run(query(:id_and_key), __MODULE__.Schema, @default_context) + end + end + + test "accepts error msg configuration" do + with_mock Hammer, [check_rate: fn _a, _b, _c -> {:deny, 1} end] do + assert {:ok, %{errors: errors}} = Absinthe.run(query(:error_msg), __MODULE__.Schema, @default_context) + assert [ + %{ + locations: [%{column: 0, line: 1}], + message: "Rate limit exceeded", + path: ["error_msg"] + } + ] == errors + end + end + + defp query(name), do: "{ #{name} }" + defp query(name, key, value) when is_binary(value), do: "{ #{name}(#{key}: \"#{value}\") }" + defp query(name, key, %{} = value), do: "{ #{name}(#{key}: {#{build_arguments(value)}}) }" + + defp build_arguments(arguments) do + arguments + |> Enum.map(fn {k, v} -> if is_nil(v), do: nil, else: "#{k}: #{inspect(v, [charlists: :as_lists])}" end) + |> Enum.reject(&is_nil/1) + |> Enum.join(", ") + end +end