diff --git a/server/lib/ethui/application.ex b/server/lib/ethui/application.ex index 8a172eb..68ab259 100644 --- a/server/lib/ethui/application.ex +++ b/server/lib/ethui/application.ex @@ -7,6 +7,13 @@ defmodule Ethui.Application do @impl true def start(_type, _args) do + # ETS cache backing EthuiWeb.Plugs.ApiKeyAuth; guard avoids raising if the + # table already exists on a re-entrant start/2. + _ = + if :ets.whereis(:api_key_cache) == :undefined do + :ets.new(:api_key_cache, [:named_table, :public, :set, read_concurrency: true]) + end + children = [ EthuiWeb.Telemetry, Ethui.Repo, diff --git a/server/lib/ethui_web/plugs/api_key_auth.ex b/server/lib/ethui_web/plugs/api_key_auth.ex index 8e340a2..4d93c39 100644 --- a/server/lib/ethui_web/plugs/api_key_auth.ex +++ b/server/lib/ethui_web/plugs/api_key_auth.ex @@ -15,6 +15,9 @@ defmodule EthuiWeb.Plugs.ApiKeyAuth do @min_token_length 20 + # Successful lookups are cached for this window; a revoked key keeps working until it lapses. + @cache_ttl_ms :timer.seconds(60) + def init(opts), do: opts def call(conn, _opts) do @@ -26,10 +29,8 @@ defmodule EthuiWeb.Plugs.ApiKeyAuth do end defp do_call(conn) do - conn.path_info - with [token | _] when byte_size(token) >= @min_token_length <- conn.path_info, - %ApiKey{} = api_key <- Accounts.get_api_key_by_token(token), + %ApiKey{} = api_key <- cached_api_key(token), true <- stack_matches?(conn, api_key) do conn |> Map.update!(:path_info, &tl/1) else @@ -41,6 +42,27 @@ defmodule EthuiWeb.Plugs.ApiKeyAuth do end end + # Cache successful token lookups so the proxy hot path skips the DB per request. + # Misses (invalid tokens) are not cached. + defp cached_api_key(token) do + now = System.monotonic_time(:millisecond) + + case :ets.lookup(:api_key_cache, token) do + [{^token, %ApiKey{} = api_key, expiry}] when expiry > now -> + api_key + + _ -> + case Accounts.get_api_key_by_token(token) do + %ApiKey{} = api_key -> + :ets.insert(:api_key_cache, {token, api_key, now + @cache_ttl_ms}) + api_key + + other -> + other + end + end + end + defp stack_matches?(conn, api_key) do case conn.assigns[:proxy][:slug] do nil -> false diff --git a/server/test/ethui_web/plugs/api_key_auth_test.exs b/server/test/ethui_web/plugs/api_key_auth_test.exs index e4c09a9..b2edb5a 100644 --- a/server/test/ethui_web/plugs/api_key_auth_test.exs +++ b/server/test/ethui_web/plugs/api_key_auth_test.exs @@ -2,10 +2,12 @@ defmodule EthuiWeb.Plugs.ApiKeyAuthTest do use EthuiWeb.ConnCase, async: false import Plug.Test + import Ecto.Query, only: [from: 2] alias EthuiWeb.Plugs.{ApiKeyAuth, Authenticate, StackSubdomain} alias Ethui.Repo alias Ethui.Accounts + alias Ethui.Accounts.ApiKey alias Ethui.Stacks.Stack describe "Api key auth plug when enabled" do @@ -49,6 +51,60 @@ defmodule EthuiWeb.Plugs.ApiKeyAuthTest do assert conn.path_info == ["execute"] refute conn.halted end + + test "caches successful lookups so the proxy hot path skips the DB", %{ + slug: slug, + api_key: api_key + } do + call = fn -> + conn(:get, "/#{api_key}/execute") + |> Map.put(:host, "#{slug}.lvh.me") + |> StackSubdomain.call(StackSubdomain.init([])) + |> ApiKeyAuth.call(ApiKeyAuth.init([])) + end + + refute call.().halted + + # warm cache authorizes even after the DB row is gone + Repo.delete_all(from(k in ApiKey, where: k.token == ^api_key)) + refute call.().halted + + # evicted -> re-checked -> now missing -> rejected + :ets.delete(:api_key_cache, api_key) + assert call.().halted + end + + test "expired cache entries are ignored and re-checked against the DB", %{ + slug: slug, + api_key: api_key + } do + call = fn -> + conn(:get, "/#{api_key}/execute") + |> Map.put(:host, "#{slug}.lvh.me") + |> StackSubdomain.call(StackSubdomain.init([])) + |> ApiKeyAuth.call(ApiKeyAuth.init([])) + end + + struct = Ethui.Accounts.get_api_key_by_token(api_key) + Repo.delete_all(from(k in ApiKey, where: k.token == ^api_key)) + + # a warm (non-expired) entry would still authorize; an expired one must not + :ets.insert(:api_key_cache, {api_key, struct, System.monotonic_time(:millisecond) - 1}) + assert call.().halted + end + + test "invalid tokens are not cached", %{slug: slug} do + bad = String.duplicate("z", 30) + + conn = + conn(:get, "/#{bad}/execute") + |> Map.put(:host, "#{slug}.lvh.me") + |> StackSubdomain.call(StackSubdomain.init([])) + |> ApiKeyAuth.call(ApiKeyAuth.init([])) + + assert conn.halted + assert :ets.lookup(:api_key_cache, bad) == [] + end end describe "Api key auth plug when disabled " do