Harden authentication token delivery limits

This commit is contained in:
SimpleTest 2026-07-28 05:51:15 +03:00
parent 042cabe2a8
commit b519f902e8
16 changed files with 386 additions and 55 deletions

View File

@ -223,4 +223,7 @@ SUPPORT_INBOX_ADDRESS=
CODEX_SESSION_ID=copy-the-main-local-codex-session-id
# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved.
# Shape: {"action_name":{"limit":POSITIVE_INTEGER,"window_seconds":POSITIVE_INTEGER}}
# Authentication delivery uses paired email/IP actions:
# registration_email + registration_ip, magic_link_email + magic_link_ip,
# password_login_email + password_login_ip, email_change_email + email_change_ip.
RATE_LIMIT_POLICIES_JSON={}

View File

@ -206,7 +206,10 @@ Action buckets live in PostgreSQL and use an atomic upsert keyed by action,
hashed scope, and aligned time window. This works across all web replicas
without an in-memory or Redis singleton. Policies are supplied through
`RATE_LIMIT_POLICIES_JSON`; no numeric product policy is compiled into the
application. Expired buckets are pruned by the maintenance worker.
application. Authentication checks combine the email and client-IP buckets in
one `INSERT ... ON CONFLICT` statement. Failed transactional-email delivery
removes only the token created for that failed attempt. Expired buckets and
tokens are pruned by the maintenance worker.
The Compose and kind scripts finish by subscribing on one live BEAM node,
broadcasting through a different connected node, and failing if the PubSub

View File

@ -5,6 +5,31 @@ pool size, or autoscaling threshold is known yet. The repository therefore
contains a reproducible measurement profile, not a capacity claim or blocking
resource preflight.
## Authentication limiter micro-measurement
Observed locally on 2026-07-28 with Elixir 1.20.2 / OTP 29.0.3, PostgreSQL
18.4, Docker 29.6.2, and the current uncommitted authentication-limiter
candidate on an AMD Ryzen 9 7950X3D workstation. PostgreSQL used an isolated
tmpfs data directory and the application connected over a Docker bridge.
Each operation incremented an email bucket and an IP bucket together. A
telemetry-backed integration test confirmed that the operation executes one
PostgreSQL `INSERT ... ON CONFLICT` statement, not two independent counter
queries.
| Observation | Result |
| --- | ---: |
| Sequential operations | 1,000 |
| Sequential p50 / p95 / p99 | 155 / 237 / 273 µs |
| Concurrent operations / concurrency | 1,000 / 50 |
| Concurrent elapsed time | 107.4 ms |
| Concurrent observed throughput | 9,308 operations/s |
This isolates the limiter on a fast local tmpfs database. It does not include
SMTP delivery, account lookup, production storage latency, internet latency,
or production contention, and therefore is not a production capacity claim.
It provides no evidence that Redis is currently required.
## Latest local candidate measurement
Observed on 2026-07-25 at application commit

View File

@ -88,17 +88,22 @@ verification.
and configured action velocity create review signals. They do not
automatically punish an account.
- PostgreSQL action buckets enforce only operator-supplied policies across all
replicas. Registration and magic-link email scopes can also be configured.
replicas. Registration, password login, magic-link delivery, and email
changes can each be limited by both normalized email and client IP in one
atomic database statement.
- No public exact location by default.
No numeric rate policy is enabled by default because no threshold has been
approved or measured for this deployment. Supported action names currently
include `registration_email`, `magic_link_email`, `create_request`,
`accept_request`, `start`, `confirm`, `verify_handover`, `cancel_request`,
`withdraw_assignment`, `send_message`, `start_tracking`, `tracking_position`,
`review`, `report`, `block`, `category_proposal`, and `category_vote`.
Activity actions additionally include `create_activity`, `join_activity`, and
`send_activity_message`.
approved for this deployment. Authentication action pairs are
`registration_email`/`registration_ip`,
`magic_link_email`/`magic_link_ip`,
`password_login_email`/`password_login_ip`, and
`email_change_email`/`email_change_ip`. Other supported action names include
`create_request`, `accept_request`, `start`, `confirm`, `verify_handover`,
`cancel_request`, `withdraw_assignment`, `send_message`, `start_tracking`,
`tracking_position`, `review`, `report`, `block`, `category_proposal`, and
`category_vote`. Activity actions additionally include `create_activity`,
`join_activity`, and `send_activity_message`.
The system does not claim to be bot-proof. Email confirmation, database
uniqueness, unique-counterpart ranking, location evidence, velocity policies,

View File

@ -900,8 +900,9 @@ defmodule WhoNeedHelp.Accounts do
when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
Repo.insert!(user_token)
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
persist_email_token_and_deliver(user_token, fn ->
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end)
end
@doc """
@ -910,16 +911,20 @@ defmodule WhoNeedHelp.Accounts do
def deliver_login_instructions(%User{} = user, magic_link_url_fun)
when is_function(magic_link_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
Repo.insert!(user_token)
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
persist_email_token_and_deliver(user_token, fn ->
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
end)
end
@doc "Delivers a one-time local-account verification link before connecting Google."
def deliver_google_link_instructions(%User{} = user, magic_link_url_fun)
when is_function(magic_link_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
Repo.insert!(user_token)
UserNotifier.deliver_google_link_instructions(user, magic_link_url_fun.(encoded_token))
persist_email_token_and_deliver(user_token, fn ->
UserNotifier.deliver_google_link_instructions(user, magic_link_url_fun.(encoded_token))
end)
end
@doc """
@ -938,6 +943,19 @@ defmodule WhoNeedHelp.Accounts do
## Token helper
defp persist_email_token_and_deliver(user_token, deliver_fun) do
persisted_token = Repo.insert!(user_token)
case deliver_fun.() do
{:ok, _email} = delivered ->
delivered
{:error, _reason} = error ->
Repo.delete_all(from token in UserToken, where: token.id == ^persisted_token.id)
error
end
end
defp before_moderation_user(query, nil), do: query
defp before_moderation_user(query, {inserted_at, id}) do

View File

@ -11,15 +11,38 @@ defmodule WhoNeedHelp.Trust.RateLimiter do
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust.RateLimitBucket
def check(action, scope) when is_atom(action), do: check(Atom.to_string(action), scope)
def check(action, scope) do
case check_many([{action, scope}]) do
{:ok, [limit]} -> {:ok, limit}
{:ok, []} -> {:ok, :not_configured}
{:error, :rate_limited} = error -> error
end
end
def check(action, scope) when is_binary(action) do
case policy(action) do
nil ->
{:ok, :not_configured}
@doc """
Atomically increments every configured action/scope pair in one PostgreSQL
statement.
%{limit: limit, window_seconds: window_seconds} ->
increment(action, scope, limit, window_seconds)
Unconfigured actions are ignored. The call fails when any configured bucket
exceeds its policy, while still counting the whole attempt consistently.
"""
def check_many(action_scopes) when is_list(action_scopes) do
now = DateTime.utc_now(:second)
prepared =
action_scopes
|> Enum.map(fn {action, scope} -> {normalize_action(action), scope} end)
|> Enum.uniq()
|> Enum.flat_map(fn {action, scope} ->
case policy(action) do
nil -> []
policy -> [prepare_bucket(action, scope, policy, now)]
end
end)
case prepared do
[] -> {:ok, []}
buckets -> increment_many(buckets)
end
end
@ -52,37 +75,70 @@ defmodule WhoNeedHelp.Trust.RateLimiter do
ArgumentError -> nil
end
defp increment(action, scope, limit, window_seconds) do
now = DateTime.utc_now(:second)
defp prepare_bucket(action, scope, %{limit: limit, window_seconds: window_seconds}, now) do
unix = DateTime.to_unix(now)
window_start = (div(unix, window_seconds) * window_seconds) |> DateTime.from_unix!()
expires_at = DateTime.add(window_start, window_seconds, :second)
scope_hash = :crypto.hash(:sha256, to_string(scope))
{_count, [bucket]} =
%{
action: action,
scope_hash: scope_hash,
window_started_at: window_start,
expires_at: expires_at,
limit: limit,
now: now
}
end
defp increment_many(buckets) do
now = buckets |> hd() |> Map.fetch!(:now)
rows =
Enum.map(buckets, fn bucket ->
%{
id: Ecto.UUID.generate(),
action: bucket.action,
scope_hash: bucket.scope_hash,
window_started_at: bucket.window_started_at,
count: 1,
expires_at: bucket.expires_at,
inserted_at: now,
updated_at: now
}
end)
{_count, returned} =
Repo.insert_all(
RateLimitBucket,
[
%{
id: Ecto.UUID.generate(),
action: action,
scope_hash: scope_hash,
window_started_at: window_start,
count: 1,
expires_at: expires_at,
inserted_at: now,
updated_at: now
}
],
on_conflict: [inc: [count: 1], set: [expires_at: expires_at, updated_at: now]],
rows,
on_conflict: [inc: [count: 1], set: [updated_at: now]],
conflict_target: [:action, :scope_hash, :window_started_at],
returning: [:count]
returning: [:action, :scope_hash, :count, :expires_at]
)
if bucket.count <= limit do
{:ok, %{count: bucket.count, limit: limit, resets_at: expires_at}}
limits =
Map.new(buckets, fn bucket ->
{{bucket.action, bucket.scope_hash}, bucket.limit}
end)
results =
Enum.map(returned, fn bucket ->
%{
action: bucket.action,
count: bucket.count,
limit: Map.fetch!(limits, {bucket.action, bucket.scope_hash}),
resets_at: bucket.expires_at
}
end)
if Enum.all?(results, &(&1.count <= &1.limit)) do
{:ok, results}
else
{:error, :rate_limited}
end
end
defp normalize_action(action) when is_atom(action), do: Atom.to_string(action)
defp normalize_action(action) when is_binary(action), do: action
end

View File

@ -0,0 +1,16 @@
defmodule WhoNeedHelpWeb.AuthRateLimit do
@moduledoc """
Applies account- and network-scoped authentication limits atomically.
"""
alias Plug.Conn
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.ClientIp
def check(%Conn{} = conn, email_action, email_scope, ip_action) do
RateLimiter.check_many([
{email_action, email_scope},
{ip_action, ClientIp.rate_limit_scope(conn)}
])
end
end

View File

@ -0,0 +1,20 @@
defmodule WhoNeedHelpWeb.ClientIp do
@moduledoc """
Produces a stable, non-secret rate-limit scope from the client address.
`Plug.RewriteOn` populates `conn.remote_ip` from the forwarding header that
Caddy sanitizes at the public edge. The PostgreSQL limiter hashes this value
before storing it.
"""
alias Plug.Conn
def rate_limit_scope(%Conn{remote_ip: address}) when is_tuple(address) do
case :inet.ntoa(address) do
value when is_list(value) -> List.to_string(value)
_invalid -> "unknown"
end
end
def rate_limit_scope(_conn), do: "unknown"
end

View File

@ -4,8 +4,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
require Logger
alias WhoNeedHelp.{Accounts, GoogleAuth, Locales, ProductAnalytics, Repo, Trust}
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.{GoogleAuthPending, UserAuth}
alias WhoNeedHelpWeb.{AuthRateLimit, GoogleAuthPending, UserAuth}
import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2]
@ -92,7 +91,13 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
def complete_registration(conn, %{"google_registration" => params}) when is_map(params) do
with {:ok, pending} <- GoogleAuthPending.fetch(conn),
nil <- Accounts.get_user_by_email(pending.email),
{:ok, _limit} <- RateLimiter.check(:registration_email, pending.email),
{:ok, _limit} <-
AuthRateLimit.check(
conn,
:registration_email,
pending.email,
:registration_ip
),
{:ok, {user, _identity}} <-
Accounts.register_user_by_google(
identity_attrs(pending),
@ -156,7 +161,8 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
%Accounts.User{moderation_status: status} = user when status != :suspended <-
Accounts.get_user_by_email(pending.email),
{:ok, pending_token} <- GoogleAuthPending.token(conn),
{:ok, _limit} <- RateLimiter.check(:magic_link_email, pending.email),
{:ok, _limit} <-
AuthRateLimit.check(conn, :magic_link_email, pending.email, :magic_link_ip),
{:ok, _email} <- deliver_google_verification(conn, user, pending_token) do
conn
|> put_flash(

View File

@ -5,7 +5,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
alias WhoNeedHelp.{Accounts, GoogleAuth, ProductAnalytics}
alias WhoNeedHelp.Accounts.User
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.AuthRateLimit
plug :put_no_store
@ -29,7 +29,8 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
user_params = normalize_registration_params(user_params)
email_scope = normalize_email_scope(user_params["email"])
with {:ok, _limit} <- RateLimiter.check(:registration_email, email_scope),
with {:ok, _limit} <-
AuthRateLimit.check(conn, :registration_email, email_scope, :registration_ip),
result <- Accounts.register_user(user_params) do
case result do
{:ok, user} ->

View File

@ -5,8 +5,7 @@ defmodule WhoNeedHelpWeb.UserSessionController do
alias WhoNeedHelp.{Accounts, GoogleAuth, Notifications}
alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.{GoogleAuthPending, UserAuth}
alias WhoNeedHelpWeb.{AuthRateLimit, GoogleAuthPending, UserAuth}
plug :assign_magic_link_form
plug :assign_page_title
@ -58,7 +57,12 @@ defmodule WhoNeedHelpWeb.UserSessionController do
when is_binary(email) and is_binary(password) do
email_scope = email |> String.trim() |> String.downcase()
case RateLimiter.check(:password_login_email, email_scope) do
case AuthRateLimit.check(
conn,
:password_login_email,
email_scope,
:password_login_ip
) do
{:ok, _limit} ->
if user = Accounts.get_user_by_email_and_password(email, password) do
conn
@ -81,7 +85,7 @@ defmodule WhoNeedHelpWeb.UserSessionController do
def create(conn, %{"user" => %{"email" => email}}) when is_binary(email) do
email_scope = email |> String.trim() |> String.downcase()
case RateLimiter.check(:magic_link_email, email_scope) do
case AuthRateLimit.check(conn, :magic_link_email, email_scope, :magic_link_ip) do
{:ok, _limit} ->
case Accounts.get_user_by_email(email) do
%Accounts.User{moderation_status: status} = user when status != :suspended ->

View File

@ -4,8 +4,7 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
require Logger
alias WhoNeedHelp.{Accounts, GoogleAuth}
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.UserAuth
alias WhoNeedHelpWeb.{AuthRateLimit, UserAuth}
import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2]
@ -29,7 +28,12 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|> String.trim()
|> String.downcase()
case RateLimiter.check(:email_change_email, new_email) do
case AuthRateLimit.check(
conn,
:email_change_email,
new_email,
:email_change_ip
) do
{:ok, _limit} ->
deliver_email_change_instructions(conn, user, changeset)

View File

@ -42,6 +42,11 @@ defmodule WhoNeedHelpWeb.Endpoint do
param_key: "request_logger",
cookie_key: "request_logger"
# Caddy is the only public entry point and replaces incoming X-Forwarded-For
# instead of trusting a client-supplied value. Keep the application bound to
# loopback/private networks when this rewrite is enabled.
plug Plug.RewriteOn, [:x_forwarded_for]
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

View File

@ -1,3 +1,10 @@
defmodule WhoNeedHelp.FailingMailerAdapter do
use Swoosh.Adapter
@impl true
def deliver(_email, _config), do: {:error, :delivery_failed}
end
defmodule WhoNeedHelp.AccountsTest do
use WhoNeedHelp.DataCase
@ -639,6 +646,28 @@ defmodule WhoNeedHelp.AccountsTest do
assert user_token.sent_to == user.email
assert user_token.context == "login"
end
test "removes the newly created token when delivery fails", %{user: user} do
previous = Application.get_env(:who_need_help, WhoNeedHelp.Mailer)
Application.put_env(
:who_need_help,
WhoNeedHelp.Mailer,
adapter: WhoNeedHelp.FailingMailerAdapter
)
on_exit(fn ->
Application.put_env(:who_need_help, WhoNeedHelp.Mailer, previous)
end)
assert {:error, :delivery_failed} =
Accounts.deliver_login_instructions(user, &"https://example.test/#{&1}")
refute Repo.exists?(
from token in UserToken,
where: token.user_id == ^user.id and token.context == "login"
)
end
end
describe "inspect/2 for the User module" do

View File

@ -7,7 +7,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
alias WhoNeedHelp.Help.Assignment
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.Position
alias WhoNeedHelp.Trust.{AbuseSignal, AuditEvent, RateLimiter}
alias WhoNeedHelp.Trust.{AbuseSignal, AuditEvent, RateLimitBucket, RateLimiter}
setup do
category = Catalog.seed_defaults()
@ -431,6 +431,107 @@ defmodule WhoNeedHelp.TrustSafetyTest do
)
end
test "multiple configured scopes are counted in one limiter call", context do
old = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"test_email" => %{"limit" => 1, "window_seconds" => 60},
"test_ip" => %{"limit" => 2, "window_seconds" => 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end)
assert {:ok, results} =
RateLimiter.check_many([
{:test_email, context.helper.email},
{:test_ip, "203.0.113.10"}
])
assert Map.new(results, &{&1.action, {&1.count, &1.limit}}) == %{
"test_email" => {1, 1},
"test_ip" => {1, 2}
}
assert Enum.all?(results, &match?(%DateTime{}, &1.resets_at))
assert {:error, :rate_limited} =
RateLimiter.check_many([
{:test_email, context.helper.email},
{:test_ip, "203.0.113.10"}
])
assert Repo.aggregate(
from(bucket in RateLimitBucket, where: bucket.action in ["test_email", "test_ip"]),
:sum,
:count
) == 4
end
test "multiple limiter scopes use one PostgreSQL statement", context do
old = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"query_count_email" => %{"limit" => 2, "window_seconds" => 60},
"query_count_ip" => %{"limit" => 2, "window_seconds" => 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end)
handler_id = "rate-limiter-query-count-#{System.unique_integer([:positive])}"
test_process = self()
:ok =
:telemetry.attach(
handler_id,
[:who_need_help, :repo, :query],
fn _event, _measurements, metadata, recipient ->
send(recipient, {:rate_limiter_query, metadata.query})
end,
test_process
)
on_exit(fn -> :telemetry.detach(handler_id) end)
assert {:ok, [_email, _ip]} =
RateLimiter.check_many([
{:query_count_email, context.helper.email},
{:query_count_ip, "203.0.113.20"}
])
assert_receive {:rate_limiter_query, query}
assert query =~ ~s(INSERT INTO "rate_limit_buckets")
refute_receive {:rate_limiter_query, _another_query}, 50
end
test "concurrent limiter calls share one database counter across processes", context do
old = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"concurrent_test" => %{"limit" => 10, "window_seconds" => 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end)
results =
1..20
|> Task.async_stream(
fn _attempt -> RateLimiter.check(:concurrent_test, context.helper.id) end,
max_concurrency: 20,
ordered: false,
timeout: 5_000
)
|> Enum.map(fn {:ok, result} -> result end)
assert Enum.count(results, &match?({:ok, _}, &1)) == 10
assert Enum.count(results, &match?({:error, :rate_limited}, &1)) == 10
assert Repo.one!(
from bucket in RateLimitBucket,
where: bucket.action == "concurrent_test",
select: bucket.count
) == 20
end
test "invalid request forms do not consume the publication rate limit", context do
old = Application.get_env(:who_need_help, :rate_limit_policies)

View File

@ -192,6 +192,41 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
end)
end
test "limits magic-link requests by forwarded client address", %{user: user} do
previous = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"magic_link_email" => %{"limit" => 10, "window_seconds" => 60},
"magic_link_ip" => %{"limit" => 1, "window_seconds" => 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, previous) end)
params = %{"user" => %{"email" => user.email}}
first =
build_conn()
|> put_req_header("x-forwarded-for", "203.0.113.10")
|> post(~p"/users/log-in", params)
assert redirected_to(first) == ~p"/users/check-email?flow=login"
limited =
build_conn()
|> put_req_header("x-forwarded-for", "203.0.113.10")
|> post(~p"/users/log-in", params)
assert limited.status == 429
assert html_response(limited, 429) =~ "Too many sign-in emails"
other_address =
build_conn()
|> put_req_header("x-forwarded-for", "203.0.113.11")
|> post(~p"/users/log-in", params)
assert redirected_to(other_address) == ~p"/users/check-email?flow=login"
end
test "does not send a magic link for a suspended user", %{conn: conn, user: user} do
{:ok, user} =
user