Skip to content

Commit

Permalink
Merge pull request #22 from rschef/rate-limiter
Browse files Browse the repository at this point in the history
Rate limiter
  • Loading branch information
rschef committed Feb 17, 2020
2 parents b9582f1 + f0de163 commit 529990e
Show file tree
Hide file tree
Showing 8 changed files with 271 additions and 0 deletions.
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ It provides the following middlewares:
- [Object Authorization](#object-authorization)
- [Object Scope Authorization](#object-scope-authorization)
- [Field Authorization](#field-authorization)
- [Rate Limiter](#rate-limiter)

Documentation can be found at [https://hexdocs.pm/rajska/](https://hexdocs.pm/rajska).

Expand Down Expand Up @@ -323,6 +324,55 @@ end

As seen in the example above, a function can also be passed as value to the meta `:private` key, in order to check if a field is private dynamically, depending of the value of another field.

### Rate Limiter

Rate limiter absinthe middleware. Uses [Hammer](https://github.com/ExHammer/hammer).

#### Usage

First configure Hammer, following its documentation. For example:

```elixir
config :hammer,
backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4,
cleanup_interval_ms: 60_000 * 10]}
```

Add your middleware to the query that should be limited:

```elixir
field :default_config, :string do
middleware Rajska.RateLimiter
resolve fn _, _ -> {:ok, "ok"} end
end
```

You can also configure it and use multiple rules for limiting in one query:

```elixir
field :login_user, :session do
arg :email, non_null(:string)
arg :password, non_null(:string)

middleware Rajska.RateLimiter, limit: 10 # Using the default identifier (user IP)
middleware Rajska.RateLimiter, keys: :email, limit: 5 # Using the value provided in the email arg
resolve &AccountsResolver.login_user/2
end
```

The allowed configuration are:

* `scale_ms`: The timespan for the maximum number of actions. Defaults to 60_000.
* `limit`: The maximum number of actions in the specified timespan. Defaults to 10.
* `id`: An atom or string to be used as the bucket identifier. Note that this will always be the same, so by using this the limit will be global instead of by user.
* `keys`: An atom or a list of atoms to get a query argument as identifier. Use a list when the argument is nested.
* `error_msg`: The error message to be displayed when rate limit exceeds. Defaults to `"Too many requests"`.

Note that when neither `id` or `keys` is provided, the default is to use the user's IP. For that, the default behaviour is to use
`c:Rajska.Authorization.get_ip/1` to fetch the IP from the absinthe context. That means you need to manually insert the user's IP in the
absinthe context before using it as an identifier. See the [absinthe docs](https://hexdocs.pm/absinthe/context-and-authentication.html#content)
for more information.

## Related Projects

[Crudry](https://github.com/gabrielpra1/crudry) is an elixir library for DRYing CRUD of Phoenix Contexts and Absinthe Resolvers.
Expand Down
3 changes: 3 additions & 0 deletions lib/authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions lib/middlewares/rate_limiter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
defmodule Rajska.RateLimiter do
@moduledoc """
Rate limiter absinthe middleware. Uses [Hammer](https://github.com/ExHammer/hammer).
## Usage
First configure Hammer, following its documentation. For example:
config :hammer,
backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4,
cleanup_interval_ms: 60_000 * 10]}
Add your middleware to the query that should be limited:
field :default_config, :string do
middleware Rajska.RateLimiter
resolve fn _, _ -> {:ok, "ok"} end
end
You can also configure it and use multiple rules for limiting in one query:
field :login_user, :session do
arg :email, non_null(:string)
arg :password, non_null(:string)
middleware Rajska.RateLimiter, limit: 10 # Using the default identifier (user IP)
middleware Rajska.RateLimiter, keys: :email, limit: 5 # Using the value provided in the email arg
resolve &AccountsResolver.login_user/2
end
The allowed configuration are:
* `scale_ms`: The timespan for the maximum number of actions. Defaults to 60_000.
* `limit`: The maximum number of actions in the specified timespan. Defaults to 10.
* `id`: An atom or string to be used as the bucket identifier. Note that this will always be the same, so by using this the limit will be global instead of by user.
* `keys`: An atom or a list of atoms to get a query argument as identifier. Use a list when the argument is nested.
* `error_msg`: The error message to be displayed when rate limit exceeds. Defaults to `"Too many requests"`.
Note that when neither `id` or `keys` is provided, the default is to use the user's IP. For that, the default behaviour is to use
`c:Rajska.Authorization.get_ip/1` to fetch the IP from the absinthe context. That means you need to manually insert the user's IP in the
absinthe context before using it as an identifier. See the [absinthe docs](https://hexdocs.pm/absinthe/context-and-authentication.html#content)
for more information.
"""
@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
2 changes: 2 additions & 0 deletions lib/rajska.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
6 changes: 6 additions & 0 deletions test/middlewares/object_authorization_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ defmodule Rajska.ObjectAuthorizationTest do
refute Map.has_key?(result, :errors)
end


test "does not apply when resolution is already resolved" do
resolution = %Absinthe.Resolution{state: :resolved}
assert resolution == Rajska.ObjectAuthorization.call(resolution, [])
end

defp all_query do
"""
{
Expand Down
133 changes: 133 additions & 0 deletions test/middlewares/rate_limiter_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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

test "does not apply when resolution is already resolved" do
resolution = %Absinthe.Resolution{state: :resolved}
assert resolution == Rajska.RateLimiter.call(resolution, [])
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

0 comments on commit 529990e

Please sign in to comment.