92 lines
2.3 KiB
Elixir
92 lines
2.3 KiB
Elixir
|
|
defmodule BerrypodWeb.Plugs.RateLimit do
|
||
|
|
@moduledoc """
|
||
|
|
Plug for rate limiting requests.
|
||
|
|
|
||
|
|
## Usage in router
|
||
|
|
|
||
|
|
plug BerrypodWeb.Plugs.RateLimit, type: :login
|
||
|
|
plug BerrypodWeb.Plugs.RateLimit, type: :api
|
||
|
|
|
||
|
|
## Types
|
||
|
|
|
||
|
|
- `:login` - 5 requests per minute per IP (auth endpoints)
|
||
|
|
- `:magic_link` - 3 requests per minute per email
|
||
|
|
- `:newsletter` - 10 requests per minute per IP
|
||
|
|
- `:api` - 60 requests per minute per IP
|
||
|
|
"""
|
||
|
|
|
||
|
|
import Plug.Conn
|
||
|
|
alias Berrypod.RateLimit
|
||
|
|
|
||
|
|
def init(opts), do: opts
|
||
|
|
|
||
|
|
def call(conn, opts) do
|
||
|
|
# Skip rate limiting in test environment
|
||
|
|
if Application.get_env(:berrypod, :env) == :test do
|
||
|
|
conn
|
||
|
|
else
|
||
|
|
do_rate_limit(conn, opts[:type])
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp do_rate_limit(conn, :login) do
|
||
|
|
case RateLimit.check_login(conn.remote_ip) do
|
||
|
|
:ok -> conn
|
||
|
|
{:error, retry_after} -> rate_limited(conn, retry_after)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp do_rate_limit(conn, :magic_link) do
|
||
|
|
# For magic link, we need to check the email from params
|
||
|
|
# This is called after parsing, so we can access body params
|
||
|
|
email = get_email_from_params(conn)
|
||
|
|
|
||
|
|
if email do
|
||
|
|
case RateLimit.check_magic_link(email) do
|
||
|
|
:ok -> conn
|
||
|
|
{:error, retry_after} -> rate_limited(conn, retry_after)
|
||
|
|
end
|
||
|
|
else
|
||
|
|
# No email in request, let it through (will fail validation anyway)
|
||
|
|
conn
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp do_rate_limit(conn, :newsletter) do
|
||
|
|
case RateLimit.check_newsletter(conn.remote_ip) do
|
||
|
|
:ok -> conn
|
||
|
|
{:error, retry_after} -> rate_limited(conn, retry_after)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp do_rate_limit(conn, :api) do
|
||
|
|
case RateLimit.check_api(conn.remote_ip) do
|
||
|
|
:ok -> conn
|
||
|
|
{:error, retry_after} -> rate_limited(conn, retry_after)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp do_rate_limit(conn, _unknown) do
|
||
|
|
conn
|
||
|
|
end
|
||
|
|
|
||
|
|
defp rate_limited(conn, retry_after_ms) do
|
||
|
|
retry_after_seconds = max(div(retry_after_ms, 1000), 1)
|
||
|
|
|
||
|
|
conn
|
||
|
|
|> put_resp_header("retry-after", Integer.to_string(retry_after_seconds))
|
||
|
|
|> put_status(:too_many_requests)
|
||
|
|
|> Phoenix.Controller.put_view(BerrypodWeb.ErrorHTML)
|
||
|
|
|> Phoenix.Controller.render("429.html")
|
||
|
|
|> halt()
|
||
|
|
end
|
||
|
|
|
||
|
|
defp get_email_from_params(conn) do
|
||
|
|
# Try common param structures for email
|
||
|
|
conn.body_params["user"]["email"] ||
|
||
|
|
conn.body_params["email"] ||
|
||
|
|
conn.params["user"]["email"] ||
|
||
|
|
conn.params["email"]
|
||
|
|
end
|
||
|
|
end
|