diff --git a/.tool-versions b/.tool-versions index 64b570d..2c22153 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ elixir 1.18.3-otp-27 -erlang 27 +erlang 27.3 diff --git a/bruno/api/stacks-api-keys/create.bru b/bruno/api/stacks-api-keys/create.bru new file mode 100644 index 0000000..a0781fe --- /dev/null +++ b/bruno/api/stacks-api-keys/create.bru @@ -0,0 +1,19 @@ +meta { + name: create + type: http + seq: 2 +} + +post { + url: {{API}}/stacks?slug=demo + body: json + auth: bearer +} + +params:query { + slug: demo +} + +auth:bearer { + token: {{TOKEN}} +} diff --git a/bruno/api/stacks-api-keys/delete.bru b/bruno/api/stacks-api-keys/delete.bru new file mode 100644 index 0000000..aff5958 --- /dev/null +++ b/bruno/api/stacks-api-keys/delete.bru @@ -0,0 +1,15 @@ +meta { + name: delete + type: http + seq: 3 +} + +delete { + url: {{API}}/stacks/:slug + body: none + auth: inherit +} + +params:path { + slug: demo +} diff --git a/bruno/api/stacks-api-keys/folder.bru b/bruno/api/stacks-api-keys/folder.bru new file mode 100644 index 0000000..0e99221 --- /dev/null +++ b/bruno/api/stacks-api-keys/folder.bru @@ -0,0 +1,4 @@ +meta { + name: /stacks/api-keys + seq: 3 +} diff --git a/bruno/api/stacks-api-keys/show.bru b/bruno/api/stacks-api-keys/show.bru new file mode 100644 index 0000000..4d8b552 --- /dev/null +++ b/bruno/api/stacks-api-keys/show.bru @@ -0,0 +1,19 @@ +meta { + name: show + type: http + seq: 1 +} + +get { + url: {{API}}/stacks/:stack-id/api-keys + body: none + auth: bearer +} + +params:path { + stack-id: 1 +} + +auth:bearer { + token: {{TOKEN}} +} diff --git a/server/config/runtime.exs b/server/config/runtime.exs index 8e9859e..0bf9c05 100644 --- a/server/config/runtime.exs +++ b/server/config/runtime.exs @@ -28,6 +28,7 @@ end is_saas? = !!System.get_env("ETHUI_STACKS_SAAS") config :ethui, EthuiWeb.Plugs.Authenticate, enabled: is_saas? +config :ethui, EthuiWeb.Plugs.ApiKeyAuth, enabled: is_saas? if config_env() == :prod do data_root = diff --git a/server/lib/ethui/accounts.ex b/server/lib/ethui/accounts.ex index d04c99d..b0d3252 100644 --- a/server/lib/ethui/accounts.ex +++ b/server/lib/ethui/accounts.ex @@ -7,6 +7,9 @@ defmodule Ethui.Accounts do alias Ethui.Repo alias Ethui.Accounts.User alias Ethui.Mailer + alias Ethui.Stacks.Stack + + alias Ethui.Accounts.ApiKey ## Database getters @@ -143,4 +146,63 @@ defmodule Ethui.Accounts do error end end + + def list_api_keys(%User{id: user_id}, slug) do + with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug) do + {:ok, Repo.all(Ecto.assoc(stack, :api_key))} + else + nil -> {:error, :not_found} + end + end + + def get_stack_api_key(%User{id: user_id}, slug) do + with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug) do + {:ok, stack.api_key} + else + nil -> {:error, :not_found} + end + end + + def create_api_key(%User{id: user_id}, slug) do + with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug), + {:ok, api_key} <- get_or_insert_api_key(stack) do + {:ok, api_key} + else + nil -> {:error, :not_found} + end + end + + def delete_api_key(%User{id: user_id}, slug) do + with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug), + %ApiKey{} = api_key <- Repo.get_by(ApiKey, stack_id: stack.id), + {:ok, _} <- Repo.delete(api_key) do + {:ok, :deleted} + else + nil -> {:error, :not_found} + error -> error + end + end + + def get_user_stack_by_slug(user_id, slug) do + Repo.get_by(Stack, slug: slug, user_id: user_id) + |> Repo.preload(:api_key) + end + + def get_or_insert_api_key(%Stack{api_key: nil} = stack) do + %ApiKey{} + |> ApiKey.changeset(%{stack_id: stack.id}) + # |> Ecto.Changeset.put_assoc(:stack, stack) + |> Repo.insert() + end + + def get_or_insert_api_key(%Stack{api_key: api_key}) do + {:ok, api_key} + end + + def get_api_key_by_token(token) do + ApiKey + |> where([k], k.token == ^token) + |> preload([:stack]) + |> Repo.one() + end end diff --git a/server/lib/ethui/accounts/user.ex b/server/lib/ethui/accounts/user.ex index 226b023..beeaa01 100644 --- a/server/lib/ethui/accounts/user.ex +++ b/server/lib/ethui/accounts/user.ex @@ -5,6 +5,7 @@ defmodule Ethui.Accounts.User do use Ecto.Schema import Ecto.Changeset + alias Ethui.Stacks.Stack schema "users" do field(:email, :string) @@ -12,6 +13,9 @@ defmodule Ethui.Accounts.User do field(:verification_code_sent_at, :naive_datetime) field(:verified_at, :naive_datetime) + has_many(:stacks, Stack) + has_many(:api_key, through: [:stacks, :api_key]) + timestamps() end diff --git a/server/lib/ethui/stacks.ex b/server/lib/ethui/stacks.ex index f47d056..d5e1496 100644 --- a/server/lib/ethui/stacks.ex +++ b/server/lib/ethui/stacks.ex @@ -4,7 +4,9 @@ defmodule Ethui.Stacks do """ alias EthuiWeb.Endpoint alias Ethui.Stacks.Server + alias Ethui.Stacks.Stack + alias Ethui.Repo @components ~w(graph graph-rpc graph-status ipfs) @reserved ~w(rpc) @@ -91,6 +93,11 @@ defmodule Ethui.Stacks do val end + @doc "Gets a stack by ID" + def get_stack(id) do + Repo.get(Stack, id) + end + defp build_url(slug) do "#{http_protocol()}#{slug}.#{host()}" end diff --git a/server/lib/ethui/stacks/api_key.ex b/server/lib/ethui/stacks/api_key.ex new file mode 100644 index 0000000..625847c --- /dev/null +++ b/server/lib/ethui/stacks/api_key.ex @@ -0,0 +1,36 @@ +defmodule Ethui.Accounts.ApiKey do + use Ecto.Schema + import Ecto.Changeset + + schema "api_keys" do + field(:token, :string) + field(:expires_at, :utc_datetime) + + belongs_to(:stack, Ethui.Stacks.Stack) + + timestamps() + end + + def changeset(api_key, attrs) do + api_key + |> cast(attrs, [:stack_id, :expires_at]) + |> validate_required([:stack_id]) + |> put_token() + |> unique_constraint(:token) + |> foreign_key_constraint(:stack_id) + end + + defp put_token(changeset) do + if get_field(changeset, :token) do + changeset + else + token = generate_token() + put_change(changeset, :token, token) + end + end + + defp generate_token do + :crypto.strong_rand_bytes(24) + |> Base.url_encode64(padding: false) + end +end diff --git a/server/lib/ethui/stacks/stack.ex b/server/lib/ethui/stacks/stack.ex index 5d35a00..7f483bb 100644 --- a/server/lib/ethui/stacks/stack.ex +++ b/server/lib/ethui/stacks/stack.ex @@ -21,6 +21,7 @@ defmodule Ethui.Stacks.Stack do field(:anvil_opts, :map, default: %{}) field(:graph_opts, :map, default: %{}) belongs_to(:user, Ethui.Accounts.User) + has_one(:api_key, Ethui.Accounts.ApiKey) timestamps(type: :utc_datetime) end diff --git a/server/lib/ethui_web/controllers/api/api_key_controller.ex b/server/lib/ethui_web/controllers/api/api_key_controller.ex new file mode 100644 index 0000000..ffcf0e4 --- /dev/null +++ b/server/lib/ethui_web/controllers/api/api_key_controller.ex @@ -0,0 +1,76 @@ +defmodule EthuiWeb.ApiKeyController do + use EthuiWeb, :controller + alias Ethui.Accounts + + def create(conn, %{"stack_slug" => slug}) do + user = conn.assigns.current_user + + case Accounts.create_api_key(user, slug) do + {:ok, api_key} -> + conn + |> put_status(:created) + |> json(%{data: serialize_api_key(api_key)}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{status: "error", error: "Stack not found"}) + + {:error, _reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{status: "error", error: "Unable to create API key"}) + end + end + + def show(conn, %{"stack_slug" => stack_slug}) do + user = conn.assigns.current_user + + case Accounts.get_stack_api_key(user, stack_slug) do + {:ok, api_key} -> + conn + |> json(%{data: serialize_api_key(api_key)}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{status: "error", error: "Stack not found"}) + + {:error, _reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{status: "error", error: "Unable to create API key"}) + end + end + + def delete(conn, %{"stack_slug" => stack_slug}) do + user = conn.assigns.current_user + + case Accounts.delete_api_key(user, stack_slug) do + {:ok, api_key} -> + conn + |> json(%{data: serialize_api_key(api_key)}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{status: "error", error: "Stack not found"}) + + {:error, _reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{status: "error", error: "Unable to create API key"}) + end + end + + defp serialize_api_key(api_key) do + %{ + id: api_key.id, + stack_slug: api_key.stack_id, + token: api_key.token, + expires_at: api_key.expires_at, + inserted_at: api_key.inserted_at, + updated_at: api_key.updated_at + } + end +end diff --git a/server/lib/ethui_web/plugs/api_key_auth.ex b/server/lib/ethui_web/plugs/api_key_auth.ex new file mode 100644 index 0000000..8e340a2 --- /dev/null +++ b/server/lib/ethui_web/plugs/api_key_auth.ex @@ -0,0 +1,54 @@ +defmodule EthuiWeb.Plugs.ApiKeyAuth do + @moduledoc """ + Authenticates requests using API keys in the URL path. + + URL format: `/:token/*rest` + Example: `https://graph-my-stack.example.com/wnlT5EkiG_pd93A1N2m7/execute` + + """ + + import Plug.Conn + import Phoenix.Controller + + alias Ethui.Accounts.ApiKey + alias Ethui.Accounts + + @min_token_length 20 + + def init(opts), do: opts + + def call(conn, _opts) do + if enabled?() do + do_call(conn) + else + conn + end + 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), + true <- stack_matches?(conn, api_key) do + conn |> Map.update!(:path_info, &tl/1) + else + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Invalid API key"}) + |> halt() + end + end + + defp stack_matches?(conn, api_key) do + case conn.assigns[:proxy][:slug] do + nil -> false + slug -> api_key.stack.slug == slug + end + end + + def enabled? do + Application.get_env(:ethui, __MODULE__)[:enabled] || false + end +end diff --git a/server/lib/ethui_web/router.ex b/server/lib/ethui_web/router.ex index 24b3556..cbb1bcb 100644 --- a/server/lib/ethui_web/router.ex +++ b/server/lib/ethui_web/router.ex @@ -35,6 +35,7 @@ defmodule EthuiWeb.Router do pipeline :proxy do plug EthuiWeb.Plugs.StackSubdomain + plug EthuiWeb.Plugs.ApiKeyAuth end scope "/", EthuiWeb, host: "api." do @@ -51,6 +52,10 @@ defmodule EthuiWeb.Router do resources "/stacks", Api.StackController, param: "slug" do # get "/logs", StackController, :logs + # + post "/api-keys", ApiKeyController, :create + get "/api-keys", ApiKeyController, :show + delete "/api-keys", ApiKeyController, :delete end end diff --git a/server/priv/repo/migrations/20251218102457_create_api_keys.exs b/server/priv/repo/migrations/20251218102457_create_api_keys.exs new file mode 100644 index 0000000..2a6d619 --- /dev/null +++ b/server/priv/repo/migrations/20251218102457_create_api_keys.exs @@ -0,0 +1,16 @@ +defmodule Ethui.Repo.Migrations.CreateApiKeys do + use Ecto.Migration + +def change do + create table(:api_keys) do + add :stack_id, references(:stacks, on_delete: :delete_all), null: false + add :token, :string, null: false + add :expires_at, :utc_datetime + + timestamps() + end + + create unique_index(:api_keys, [:token]) + create index(:api_keys, [:stack_id]) + end +end diff --git a/server/test/ethui_web/controllers/api/api_key_controller_test.exs b/server/test/ethui_web/controllers/api/api_key_controller_test.exs new file mode 100644 index 0000000..a40a9d8 --- /dev/null +++ b/server/test/ethui_web/controllers/api/api_key_controller_test.exs @@ -0,0 +1,195 @@ +defmodule EthuiWeb.Api.ApiKeyControllerTest do + use EthuiWeb.ConnCase, async: true + + alias Ethui.Repo + alias Ethui.Stacks.Stack + alias Ethui.Accounts.User + + setup do + Ecto.Adapters.SQL.Sandbox.checkout(Repo, sandbox: false) + cleanup() + + # Ensure auth is enabled for all tests + original_config = Application.get_env(:ethui, EthuiWeb.Plugs.Authenticate, []) + Application.put_env(:ethui, EthuiWeb.Plugs.Authenticate, enabled: true) + + on_exit(fn -> + Application.put_env(:ethui, EthuiWeb.Plugs.Authenticate, original_config) + end) + + :ok + end + + defp cleanup do + Repo.delete_all(Stack) + Repo.delete_all(User) + end + + defp create_authenticated_conn(email \\ nil) do + email = email || "test-#{System.unique_integer([:positive])}@example.com" + {:ok, user} = Ethui.Accounts.send_verification_code(email) + {:ok, token} = Ethui.Accounts.generate_token(user) + + Phoenix.ConnTest.build_conn(:post, "http://api.lvh.me", nil) + |> Plug.Conn.put_req_header("authorization", "Bearer #{token}") + end + + defp create_stack_conn(slug \\ "slug") do + create_authenticated_conn() + |> post(~p"/stacks", %{slug: slug}) + end + + describe "create/2" do + test "creates api key for stack" do + conn = + create_stack_conn() + |> post(~p"/stacks/slug/api-keys", %{}) + + assert response(conn, 201) + end + + test "return already existing key if its already created" do + conn = + create_stack_conn() + |> post(~p"/stacks/slug/api-keys", %{}) + + res = json_response(conn, 201) + + conn + |> post(~p"/stacks/slug/api-keys", %{}) + + res2 = json_response(conn, 201) + + assert res["data"]["token"] == res2["data"]["token"] + end + + test "return 404 if stack is from other user" do + slug = "demo" + create_stack_conn(slug) + + conn = + create_authenticated_conn("other@email.com") + |> post(~p"/stacks/#{slug}/api-keys", %{}) + + assert response(conn, 404) + end + + test "return 404 if stack doesn't exist" do + conn = + create_authenticated_conn("other@email.com") + |> post(~p"/stacks/WRONG/api-keys", %{}) + + assert response(conn, 404) + end + end + + describe "show/2" do + test "show api key for stack" do + slug = "slug" + + conn = + create_stack_conn(slug) + |> post(~p"/stacks/#{slug}/api-keys", %{}) + + res = json_response(conn, 201) + token = res["data"]["token"] + + conn = + conn + |> get(~p"/stacks/#{slug}/api-keys") + + res = json_response(conn, 200) + assert res["data"]["token"] == token + end + + test "return 404 if api key doesn't exist" do + slug = "slug" + + create_stack_conn(slug) + + conn = + create_authenticated_conn() + |> get(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + + test "return 404 if stack is from other user" do + slug = "slug" + + create_stack_conn(slug) + |> post(~p"/stacks/#{slug}/api-keys", %{}) + + conn = + create_authenticated_conn("other@email.com") + |> get(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + + test "return 404 if stack doesn't exist" do + conn = + create_authenticated_conn() + |> get(~p"/stacks/WRONG/api-keys") + + assert response(conn, 404) + end + end + + describe "delete/2" do + test "delete api key for stack" do + slug = "slug" + + conn = + create_stack_conn(slug) + |> post(~p"/stacks/#{slug}/api-keys", %{}) + + assert response(conn, 201) + + conn = + create_authenticated_conn() + |> delete(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + + conn = + create_authenticated_conn() + |> get(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + + test "return 404 if api key doesn't exist" do + slug = "slug" + + create_stack_conn(slug) + + conn = + create_authenticated_conn() + |> delete(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + + test "return 404 if stack is from other user" do + slug = "slug" + + create_stack_conn(slug) + |> post(~p"/stacks/#{slug}/api-keys", %{}) + + conn = + create_authenticated_conn("other@email.com") + |> delete(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + + test "return 404 if stack doesn't exist" do + conn = + create_authenticated_conn() + |> delete(~p"/stacks/WRONG/api-keys") + + assert response(conn, 404) + end + end +end diff --git a/server/test/ethui_web/plugs/api_key_auth_test.exs b/server/test/ethui_web/plugs/api_key_auth_test.exs new file mode 100644 index 0000000..168aa01 --- /dev/null +++ b/server/test/ethui_web/plugs/api_key_auth_test.exs @@ -0,0 +1,98 @@ +defmodule EthuiWeb.Plugs.ApiKeyAuthTest do + use EthuiWeb.ConnCase, async: false + + import Plug.Test + + alias EthuiWeb.Plugs.{ApiKeyAuth, Authenticate, StackSubdomain} + alias Ethui.Repo + alias Ethui.Accounts + alias Ethui.Stacks.Stack + + describe "Api key auth plug when enabled" do + setup do + # Ensure auth is enabled for these tests + original_config_auth = Application.get_env(:ethui, Authenticate, []) + original_config_api_key = Application.get_env(:ethui, ApiKeyAuth, []) + Application.put_env(:ethui, Authenticate, enabled: true) + Application.put_env(:ethui, ApiKeyAuth, enabled: true) + + on_exit(fn -> + Application.put_env(:ethui, Authenticate, original_config_auth) + Application.put_env(:ethui, ApiKeyAuth, original_config_api_key) + end) + + # Create a user and get a valid token + email = "auth-plug-test@example.com" + {:ok, _user} = Accounts.send_verification_code(email) + user = Accounts.get_user_by_email(email) + + slug = "example" + {:ok, token} = Accounts.verify_code_and_generate_token(email, user.verification_code) + {:ok, stack} = Stack.create_changeset(%{user_id: user.id, slug: slug}) |> Repo.insert() + {:ok, api_key} = Accounts.create_api_key(user, stack.slug) + + %{user: user, token: token, email: email, slug: slug, api_key: api_key.token} + end + + test "allows request with valid api token", %{ + token: token, + slug: slug, + api_key: api_key + } do + conn = + conn(:get, "/#{api_key}/execute") + |> Map.put(:host, "#{slug}.lvh.me") + |> put_req_header("authorization", "Bearer #{token}") + |> StackSubdomain.call(StackSubdomain.init([])) + |> ApiKeyAuth.call(ApiKeyAuth.init([])) + + assert conn.assigns[:proxy].slug == slug + assert conn.path_info == ["execute"] + refute conn.halted + end + end + + describe "Api key auth plug when disabled " do + setup do + # Ensure auth is enabled for these tests + original_config_auth = Application.get_env(:ethui, Authenticate, []) + original_config_api_key = Application.get_env(:ethui, ApiKeyAuth, []) + Application.put_env(:ethui, Authenticate, enabled: false) + Application.put_env(:ethui, ApiKeyAuth, enabled: false) + + on_exit(fn -> + Application.put_env(:ethui, Authenticate, original_config_auth) + Application.put_env(:ethui, ApiKeyAuth, original_config_api_key) + end) + + # Create a user and get a valid token + email = "auth-plug-test@example.com" + {:ok, _user} = Accounts.send_verification_code(email) + user = Accounts.get_user_by_email(email) + + slug = "example" + {:ok, token} = Accounts.verify_code_and_generate_token(email, user.verification_code) + {:ok, stack} = Stack.create_changeset(%{user_id: user.id, slug: slug}) |> Repo.insert() + {:ok, api_key} = Accounts.create_api_key(user, stack.slug) + + %{user: user, token: token, email: email, slug: slug, api_key: api_key.token} + end + + test "allows request with valid api token", %{ + token: token, + slug: slug, + api_key: api_key + } do + conn = + conn(:get, "/#{api_key}/execute") + |> Map.put(:host, "#{slug}.lvh.me") + |> put_req_header("authorization", "Bearer #{token}") + |> StackSubdomain.call(StackSubdomain.init([])) + |> ApiKeyAuth.call(ApiKeyAuth.init([])) + + assert conn.assigns[:proxy].slug == slug + assert conn.path_info == [api_key, "execute"] + refute conn.halted + end + end +end