defmodule ToweropsWeb.Plugs.RateLimit do @moduledoc """ Rate limiting plug using Hammer to prevent brute-force attacks. Applies different rate limits based on the type of endpoint: - Auth endpoints (login, register, password reset): 10 requests per minute per IP - API endpoints: 100 requests per minute per IP Uses the client's real IP address from X-Forwarded-For or X-Real-IP headers when behind a proxy/load balancer. """ import Phoenix.Controller, only: [json: 2] import Plug.Conn require Logger @auth_limit 10 @auth_period to_timeout(minute: 1) @api_limit 1000 @api_period to_timeout(minute: 1) @type limit_type :: :auth | :api def init(opts) do type = Keyword.get(opts, :type, :api) if type not in [:auth, :api] do raise ArgumentError, "RateLimit plug :type must be :auth or :api, got: #{inspect(type)}" end type end def call(conn, type) do if rate_limiting_enabled?() do check_rate_limit(conn, type) else conn end end defp rate_limiting_enabled? do Application.get_env(:towerops, :rate_limiting_enabled, true) end defp check_rate_limit(conn, type) do remote_ip = get_remote_ip(conn) bucket_key = "#{type}:#{remote_ip}" {limit, period} = limits_for(type) case Towerops.RateLimit.hit(bucket_key, period, limit) do {:allow, _count} -> conn {:deny, retry_after} -> Logger.warning("Rate limit exceeded for #{bucket_key}, retry_after: #{retry_after}ms") conn |> put_resp_header("retry-after", Integer.to_string(div(retry_after, 1000))) |> put_status(:too_many_requests) |> json(%{error: "Too many requests. Please try again later."}) |> halt() end end defp limits_for(:auth), do: {@auth_limit, @auth_period} defp limits_for(:api), do: {@api_limit, @api_period} defp get_remote_ip(conn) do # Try X-Forwarded-For first (set by Traefik and other proxies) case get_req_header(conn, "x-forwarded-for") do [forwarded | _] -> forwarded |> String.split(",") |> List.first() |> String.trim() [] -> get_fallback_ip(conn) end end defp get_fallback_ip(conn) do case get_req_header(conn, "x-real-ip") do [real_ip | _] -> real_ip [] -> format_remote_ip(conn.remote_ip) end end defp format_remote_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}" defp format_remote_ip({a, b, c, d, e, f, g, h}) do Enum.map_join([a, b, c, d, e, f, g, h], ":", &Integer.to_string(&1, 16)) end end