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).
91 lines
2.7 KiB
Elixir
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
|