towerops/lib/towerops_web/plugs/rate_limit.ex
Graham McIntire 8338d0af1e fix: remaining bugs.md findings — Repo calls, process dict, CSS, rate limits, Oban, debug route
- Extract Repo calls from LiveViews into Agents.get_agent_token_for_org/2
  and Accounts.verify_new_totp_device/2 context functions
- Remove Process dictionary usage for cookie consent; use explicit assigns
- Reject url() references in status page custom CSS changeset
- Add per-organization rate limit check alongside per-IP
- Document Oban flush as emergency-only with audit logging
- Gate /admin/headers behind dev_routes (debug endpoint)
2026-05-11 19:34:18 -05:00

95 lines
2.6 KiB
Elixir

defmodule ToweropsWeb.Plugs.RateLimit do
@moduledoc """
Rate limiting plug backed by `Towerops.RateLimit` 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: 1000 requests per minute per IP
- Admin endpoints: 100 requests per minute per IP (superuser access)
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
alias ToweropsWeb.RemoteIp
require Logger
@auth_limit 10
@auth_period to_timeout(minute: 1)
@api_limit 1000
@api_period to_timeout(minute: 1)
@admin_limit 100
@admin_period to_timeout(minute: 1)
@type limit_type :: :auth | :api | :admin
def init(opts) do
type = Keyword.get(opts, :type, :api)
if type not in [:auth, :api, :admin] do
raise ArgumentError, "RateLimit plug :type must be :auth, :api, or :admin, 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 = RemoteIp.from_conn(conn)
{limit, period} = limits_for(type)
ip_key = "#{type}:#{remote_ip}"
case Towerops.RateLimit.hit(ip_key, period, limit) do
{:allow, _count} ->
check_org_rate_limit(conn, type, period, limit)
{:deny, retry_after} ->
Logger.warning("Rate limit exceeded for #{ip_key}, retry_after: #{retry_after}ms")
halt_rate_limit(conn, retry_after)
end
end
defp check_org_rate_limit(conn, type, period, limit) do
org_id = Map.get(conn.assigns, :current_organization_id)
if org_id do
org_key = "#{type}:org:#{org_id}"
case Towerops.RateLimit.hit(org_key, period, limit) do
{:allow, _} -> conn
{:deny, retry_after} -> halt_rate_limit(conn, retry_after)
end
else
conn
end
end
defp halt_rate_limit(conn, retry_after) do
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
defp limits_for(:auth), do: {@auth_limit, @auth_period}
defp limits_for(:api), do: {@api_limit, @api_period}
defp limits_for(:admin), do: {@admin_limit, @admin_period}
end