prop/lib/microwaveprop_web/api/rate_limiter.ex
Graham McIntire c6d2c48264
feat: secure /api/v1 REST API for regular-user actions
Adds bearer-token authenticated REST API at /api/v1 covering every
action a non-admin user can perform on the website: contact + beacon
submission, beacon-monitor management, propagation queries, profile
read/update, and self-service API token issuance/revocation.

Security: SHA-256-hashed bearer tokens (mwp_ prefix, plaintext shown
once at creation), RFC 9457 problem+json error responses, RFC 9651
RateLimit-* headers backed by an ETS bucket (600/min per token,
60/min per anonymous IP, 30/min on /auth/tokens), private-contact
filtering by viewer.

Docs at docs/api/README.md (prose reference) and docs/api/openapi.yaml
(OpenAPI 3.1 spec covering every endpoint, response, and schema).

Tests: 124 new tests across schema, plug, error renderer, rate
limiter, fallback, and every controller. 16/17 API modules at 100%
line coverage; FallbackController at 87.5% (one defmodule line, an
Erlang-cover artifact for action_fallback-only modules).
2026-05-09 08:59:54 -05:00

91 lines
2.7 KiB
Elixir

defmodule MicrowavepropWeb.Api.RateLimiter do
@moduledoc """
Tiny ETS-backed fixed-window rate limiter for `/api/v1`.
No external dependency, no supervisor entry — the named ETS table
is created lazily on the first request and survives for the
lifetime of the BEAM. Each (bucket, window) pair holds a counter
incremented atomically via `:ets.update_counter/4`.
Default limits (overridable per plug invocation):
* authenticated requests: 600 / minute, keyed by API token id
* anonymous requests: 60 / minute, keyed by client IP
The plug emits the RFC 9651 `RateLimit` headers and a 429
problem+json response when the bucket is exhausted.
"""
@behaviour Plug
import Plug.Conn
alias MicrowavepropWeb.Api.ErrorJSON
@table :microwaveprop_api_rate_limiter
@default_window_ms 60_000
@default_anon_limit 60
@default_auth_limit 600
@doc """
Initializes the named ETS table. Idempotent — safe to call from
application boot and from tests. Returns `:ok` whether the table
existed already or was created by this call.
"""
@spec init_table() :: :ok
def init_table do
if :ets.whereis(@table) == :undefined do
:ets.new(@table, [:set, :public, :named_table, write_concurrency: true])
end
:ok
end
@doc "Resets the ETS table. Test helper."
@spec reset() :: :ok
def reset do
init_table()
:ets.delete_all_objects(@table)
:ok
end
@impl true
def init(opts), do: opts
@impl true
def call(conn, opts) do
init_table()
now = System.system_time(:millisecond)
window_ms = Keyword.get(opts, :window_ms, @default_window_ms)
{bucket, limit} = bucket_for(conn, opts)
window = div(now, window_ms)
key = {bucket, window}
count = :ets.update_counter(@table, key, {2, 1}, {key, 0})
remaining = max(limit - count, 0)
reset_in = div((window + 1) * window_ms - now + 999, 1000)
conn =
conn
|> put_resp_header("ratelimit-limit", Integer.to_string(limit))
|> put_resp_header("ratelimit-remaining", Integer.to_string(remaining))
|> put_resp_header("ratelimit-reset", Integer.to_string(reset_in))
if count > limit do
conn
|> put_resp_header("retry-after", Integer.to_string(reset_in))
|> ErrorJSON.send_problem(429, "too_many_requests", "Rate limit exceeded; retry after #{reset_in}s.")
else
conn
end
end
defp bucket_for(conn, opts) do
case conn.assigns[:current_api_token] do
%{id: id} ->
{{:token, id}, Keyword.get(opts, :auth_limit, @default_auth_limit)}
_ ->
ip = conn.remote_ip |> :inet.ntoa() |> to_string()
{{:ip, ip}, Keyword.get(opts, :anon_limit, @default_anon_limit)}
end
end
end