From 70a03d39e6fd3c434c650ce29307dfedb521ff69 Mon Sep 17 00:00:00 2001 From: Andrew Dryga Date: Mon, 22 May 2023 19:49:50 -0600 Subject: [PATCH] Implementing channels logic (#1619) --- .github/workflows/test.yml | 13 +- Dockerfile.dev | 2 +- Dockerfile.prod | 10 +- apps/api/lib/api/client/channel.ex | 122 +++++- apps/api/lib/api/client/socket.ex | 2 - apps/api/lib/api/client/views/interface.ex | 11 + apps/api/lib/api/client/views/relay.ex | 39 ++ apps/api/lib/api/client/views/resource.ex | 16 + apps/api/lib/api/gateway/channel.ex | 83 ++++- apps/api/lib/api/gateway/socket.ex | 3 +- apps/api/lib/api/gateway/views/actor.ex | 9 + apps/api/lib/api/gateway/views/client.ex | 17 + apps/api/lib/api/gateway/views/interface.ex | 10 + apps/api/lib/api/gateway/views/relay.ex | 5 + apps/api/lib/api/gateway/views/resource.ex | 12 + apps/api/lib/api/relay/channel.ex | 3 +- apps/api/lib/api/relay/socket.ex | 3 +- apps/api/test/api/client/channel_test.exs | 234 +++++++++++- apps/api/test/api/client/socket_test.exs | 2 +- apps/api/test/api/gateway/channel_test.exs | 195 +++++++++- apps/api/test/api/gateway/socket_test.exs | 2 +- apps/api/test/api/relay/channel_test.exs | 14 +- apps/api/test/api/relay/socket_test.exs | 2 +- apps/domain/lib/domain/actors.ex | 4 +- apps/domain/lib/domain/auth.ex | 116 +++--- apps/domain/lib/domain/auth/adapter.ex | 2 +- apps/domain/lib/domain/auth/adapters.ex | 8 +- apps/domain/lib/domain/auth/adapters/email.ex | 13 +- .../domain/auth/adapters/openid_connect.ex | 39 +- .../lib/domain/auth/adapters/userpass.ex | 94 ++++- .../auth/adapters/userpass/changeset.ex | 15 - .../domain/auth/adapters/userpass/password.ex | 10 + .../adapters/userpass/password/changeset.ex | 24 ++ apps/domain/lib/domain/auth/identities.ex | 2 - apps/domain/lib/domain/auth/identity.ex | 2 +- apps/domain/lib/domain/auth/provider.ex | 2 +- apps/domain/lib/domain/auth/providers.ex | 2 - apps/domain/lib/domain/auth/roles.ex | 3 +- apps/domain/lib/domain/auth/subject.ex | 7 +- apps/domain/lib/domain/changeset.ex | 2 +- apps/domain/lib/domain/clients.ex | 6 +- .../domain/lib/domain/config/configuration.ex | 2 + apps/domain/lib/domain/gateways.ex | 23 +- apps/domain/lib/domain/gateways/gateway.ex | 2 + .../lib/domain/gateways/gateway/query.ex | 17 + .../lib/domain/gateways/token/changeset.ex | 2 +- apps/domain/lib/domain/relays.ex | 35 +- apps/domain/lib/domain/relays/relay.ex | 4 +- .../lib/domain/relays/relay/changeset.ex | 5 +- apps/domain/lib/domain/relays/relay/query.ex | 10 +- .../lib/domain/relays/token/changeset.ex | 2 +- apps/domain/lib/domain/resources.ex | 117 ++++++ .../domain/lib/domain/resources/authorizer.ex | 25 ++ .../domain/lib/domain/resources/connection.ex | 11 + .../domain/resources/connection/changeset.ex | 16 + .../lib/domain/resources/connection/query.ex | 11 + apps/domain/lib/domain/resources/resource.ex | 23 ++ .../domain/resources/resource/changeset.ex | 63 ++++ .../lib/domain/resources/resource/query.ex | 16 + apps/domain/lib/domain/types/int4range.ex | 5 + apps/domain/lib/domain/validator.ex | 7 + apps/domain/mix.exs | 3 +- .../20230405182924_create_relays.exs | 2 + ...01206_create_resources_and_connections.exs | 68 ++++ apps/domain/priv/repo/seeds.exs | 339 ++++++----------- apps/domain/test/domain/actors_test.exs | 1 + .../test/domain/auth/adapters/email_test.exs | 97 ++++- .../auth/adapters/openid_connect_test.exs | 23 +- .../domain/auth/adapters/userpass_test.exs | 141 +++++++ .../domain/auth/adapters/userpass_text.exs | 107 ------ apps/domain/test/domain/auth_test.exs | 38 ++ apps/domain/test/domain/clients_test.exs | 4 + apps/domain/test/domain/gateways_test.exs | 41 +- apps/domain/test/domain/relays_test.exs | 63 +++- apps/domain/test/domain/resources_test.exs | 351 ++++++++++++++++++ apps/domain/test/support/data_case.ex | 5 + .../test/support/fixtures/auth_fixtures.ex | 36 +- .../support/fixtures/resources_fixtures.ex | 52 +++ apps/web/test/support/mailer_test_adapter.ex | 15 + config/config.exs | 3 +- 80 files changed, 2425 insertions(+), 520 deletions(-) create mode 100644 apps/api/lib/api/client/views/interface.ex create mode 100644 apps/api/lib/api/client/views/relay.ex create mode 100644 apps/api/lib/api/client/views/resource.ex create mode 100644 apps/api/lib/api/gateway/views/actor.ex create mode 100644 apps/api/lib/api/gateway/views/client.ex create mode 100644 apps/api/lib/api/gateway/views/interface.ex create mode 100644 apps/api/lib/api/gateway/views/relay.ex create mode 100644 apps/api/lib/api/gateway/views/resource.ex delete mode 100644 apps/domain/lib/domain/auth/adapters/userpass/changeset.ex create mode 100644 apps/domain/lib/domain/auth/adapters/userpass/password.ex create mode 100644 apps/domain/lib/domain/auth/adapters/userpass/password/changeset.ex delete mode 100644 apps/domain/lib/domain/auth/identities.ex delete mode 100644 apps/domain/lib/domain/auth/providers.ex create mode 100644 apps/domain/lib/domain/resources.ex create mode 100644 apps/domain/lib/domain/resources/authorizer.ex create mode 100644 apps/domain/lib/domain/resources/connection.ex create mode 100644 apps/domain/lib/domain/resources/connection/changeset.ex create mode 100644 apps/domain/lib/domain/resources/connection/query.ex create mode 100644 apps/domain/lib/domain/resources/resource.ex create mode 100644 apps/domain/lib/domain/resources/resource/changeset.ex create mode 100644 apps/domain/lib/domain/resources/resource/query.ex create mode 100644 apps/domain/priv/repo/migrations/20230512201206_create_resources_and_connections.exs create mode 100644 apps/domain/test/domain/auth/adapters/userpass_test.exs delete mode 100644 apps/domain/test/domain/auth/adapters/userpass_text.exs create mode 100644 apps/domain/test/domain/resources_test.exs create mode 100644 apps/domain/test/support/fixtures/resources_fixtures.ex create mode 100644 apps/web/test/support/mailer_test_adapter.ex diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 202c08baa..47d54ac5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,8 @@ -name: Test +name: Elixir on: - # TODO: Enable this for PRs against `cloud` when chaos settles - # pull_request: + pull_request: + branches: + - cloud push: branches: - cloud @@ -58,7 +59,7 @@ jobs: - name: Compile Dependencies run: mix deps.compile --skip-umbrella-children - name: Compile Application - run: mix compile + run: mix compile --warnings-as-errors - name: Setup Database run: | mix ecto.create @@ -68,7 +69,7 @@ jobs: E2E_MAX_WAIT_SECONDS: 20 run: | # XXX: This can fail when coveralls is down - mix coveralls.github --umbrella --warnings-as-errors + mix test --warnings-as-errors - name: Test Report uses: dorny/test-reporter@v1 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && (success() || failure()) }} @@ -412,7 +413,7 @@ jobs: with: platforms: linux/amd64 build-args: | - VERSION=${{ github.sha }} + VERSION=0.0.0-dev.${{ github.sha }} file: Dockerfile.prod context: . push: false diff --git a/Dockerfile.dev b/Dockerfile.dev index e3028b842..a1aabbdae 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -48,7 +48,7 @@ RUN mix do deps.get, deps.compile, compile # busting the Docker build cache unnecessarily COPY apps/web/assets/package.json /var/app/apps/web/assets/package.json COPY apps/web/assets/yarn.lock /var/app/apps/web/assets/yarn.lock -RUN cd apps/web/assets && yarn install +RUN cd apps/web/assets && mix do assets.setup, assets.deploy COPY config /var/app/config COPY apps /var/app/apps diff --git a/Dockerfile.prod b/Dockerfile.prod index 7abd6bf29..a7f5fc64e 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -43,11 +43,11 @@ ARG VERSION=0.0.0-docker ENV VERSION=$VERSION # compile assets -RUN cd apps/web/assets \ - && yarn install --frozen-lockfile \ - && yarn deploy \ - && cd .. \ - && mix phx.digest +RUN mix tailwind.install --if-missing \ + && mix esbuild.install --if-missing \ + && mix tailwind default --minify \ + && mix esbuild default --minify \ + && mix phx.digest # Compile the release RUN mix compile diff --git a/apps/api/lib/api/client/channel.ex b/apps/api/lib/api/client/channel.ex index cbba437cc..bca406934 100644 --- a/apps/api/lib/api/client/channel.ex +++ b/apps/api/lib/api/client/channel.ex @@ -1,20 +1,126 @@ defmodule API.Client.Channel do use API, :channel - alias Domain.Clients - - # TODO: we need to self-terminate channel once the user token is set to expire, preventing - # users from holding infinite session for if they want to keep websocket open for a while + alias API.Client.Views + alias Domain.{Clients, Resources, Gateways, Relays} @impl true def join("client", _payload, socket) do - send(self(), :after_join) - {:ok, socket} + expires_in = + DateTime.diff(socket.assigns.subject.expires_at, DateTime.utc_now(), :millisecond) + + if expires_in > 0 do + Process.send_after(self(), :token_expired, expires_in) + send(self(), :after_join) + {:ok, socket} + else + {:error, %{"reason" => "token_expired"}} + end end @impl true def handle_info(:after_join, socket) do - :ok = Clients.connect_client(socket.assigns.client, socket) - :ok = push(socket, "resources", %{resources: []}) + {:ok, resources} = Domain.Resources.list_resources(socket.assigns.subject) + + :ok = + push(socket, "init", %{ + resources: Views.Resource.render_many(resources), + interface: Views.Interface.render(socket.assigns.client) + }) + + :ok = Clients.connect_client(socket.assigns.client) + {:noreply, socket} end + + def handle_info(:token_expired, socket) do + push(socket, "token_expired", %{}) + {:stop, :token_expired, socket} + end + + def handle_info( + {:connect, socket_ref, resource_id, gateway_public_key, rtc_session_description}, + socket + ) do + reply( + socket_ref, + {:ok, + %{ + resource_id: resource_id, + persistent_keepalive: 25, + gateway_public_key: gateway_public_key, + gateway_rtc_session_description: rtc_session_description + }} + ) + + {:noreply, socket} + end + + def handle_info({:resource_added, resource_id}, socket) do + with {:ok, resource} <- Resources.fetch_resource_by_id(resource_id, socket.assigns.subject) do + push(socket, "resource_added", Views.Resource.render(resource)) + end + + {:noreply, socket} + end + + def handle_info({:resource_updated, resource_id}, socket) do + with {:ok, resource} <- Resources.fetch_resource_by_id(resource_id, socket.assigns.subject) do + push(socket, "resource_updated", Views.Resource.render(resource)) + end + + {:noreply, socket} + end + + def handle_info({:resource_removed, resource_id}, socket) do + push(socket, "resource_removed", resource_id) + {:noreply, socket} + end + + @impl true + def handle_in("list_relays", %{"resource_id" => resource_id}, socket) do + with {:ok, resource} <- Resources.fetch_resource_by_id(resource_id, socket.assigns.subject), + # :ok = Resource.authorize(resource, socket.assigns.subject), + {:ok, [_ | _] = relays} <- Relays.list_connected_relays_for_resource(resource) do + reply = {:ok, %{relays: Views.Relay.render_many(relays, socket.assigns.expires_at)}} + {:reply, reply, socket} + else + {:ok, []} -> {:reply, {:error, :offline}, socket} + {:error, :not_found} -> {:reply, {:error, :not_found}, socket} + end + end + + def handle_in( + "request_connection", + %{ + "resource_id" => resource_id, + "client_rtc_session_description" => client_rtc_session_description, + "client_preshared_key" => preshared_key + }, + socket + ) do + with {:ok, resource} <- Resources.fetch_resource_by_id(resource_id, socket.assigns.subject), + # :ok = Resource.authorize(resource, socket.assigns.subject), + {:ok, [_ | _] = gateways} <- + Gateways.list_connected_gateways_for_resource(resource) do + gateway = Enum.random(gateways) + + Phoenix.PubSub.broadcast( + Domain.PubSub, + API.Gateway.Socket.id(gateway), + {:request_connection, {self(), socket_ref(socket)}, + %{ + client_id: socket.assigns.client.id, + resource_id: resource_id, + authorization_expires_at: socket.assigns.expires_at, + client_rtc_session_description: client_rtc_session_description, + client_preshared_key: preshared_key + }} + ) + + {:noreply, socket} + else + {:error, :not_found} -> {:reply, {:error, :not_found}, socket} + {:ok, []} -> {:reply, {:error, :offline}, socket} + end + end end diff --git a/apps/api/lib/api/client/socket.ex b/apps/api/lib/api/client/socket.ex index c5b381516..28fd718e6 100644 --- a/apps/api/lib/api/client/socket.ex +++ b/apps/api/lib/api/client/socket.ex @@ -12,8 +12,6 @@ defmodule API.Client.Socket do def connect(%{"token" => token} = attrs, socket, connect_info) do %{user_agent: user_agent, peer_data: %{address: remote_ip}} = connect_info - # TODO: we want to scope tokens for specific use cases, so token generated in auth flow - # should be only good for websockets, but not to be put in a browser cookie with {:ok, subject} <- Auth.sign_in(token, user_agent, remote_ip), {:ok, client} <- Clients.upsert_client(attrs, subject) do socket = diff --git a/apps/api/lib/api/client/views/interface.ex b/apps/api/lib/api/client/views/interface.ex new file mode 100644 index 000000000..ee5725d95 --- /dev/null +++ b/apps/api/lib/api/client/views/interface.ex @@ -0,0 +1,11 @@ +defmodule API.Client.Views.Interface do + alias Domain.Clients + + def render(%Clients.Client{} = client) do + %{ + upstream_dns: Domain.Config.fetch_config!(:default_client_dns), + ipv4: client.ipv4, + ipv6: client.ipv6 + } + end +end diff --git a/apps/api/lib/api/client/views/relay.ex b/apps/api/lib/api/client/views/relay.ex new file mode 100644 index 000000000..dada01721 --- /dev/null +++ b/apps/api/lib/api/client/views/relay.ex @@ -0,0 +1,39 @@ +defmodule API.Client.Views.Relay do + alias Domain.Relays + + def render_many(relays, expires_at) do + Enum.flat_map(relays, &render(&1, expires_at)) + end + + def render(%Relays.Relay{} = relay, expires_at) do + [ + maybe_render(relay, expires_at, relay.ipv4), + maybe_render(relay, expires_at, relay.ipv6) + ] + |> List.flatten() + end + + defp maybe_render(%Relays.Relay{}, _expires_at, nil), do: [] + + defp maybe_render(%Relays.Relay{} = relay, expires_at, address) do + %{ + username: username, + password: password, + expires_at: expires_at + } = Relays.generate_username_and_password(relay, expires_at) + + [ + %{ + type: :stun, + uri: "stun:#{address}:#{relay.port}" + }, + %{ + type: :turn, + uri: "turn:#{address}:#{relay.port}", + username: username, + password: password, + expires_at: expires_at + } + ] + end +end diff --git a/apps/api/lib/api/client/views/resource.ex b/apps/api/lib/api/client/views/resource.ex new file mode 100644 index 000000000..1419f04ad --- /dev/null +++ b/apps/api/lib/api/client/views/resource.ex @@ -0,0 +1,16 @@ +defmodule API.Client.Views.Resource do + alias Domain.Resources + + def render_many(resources) do + Enum.map(resources, &render/1) + end + + def render(%Resources.Resource{} = resource) do + %{ + id: resource.id, + address: resource.address, + ipv4: resource.ipv4, + ipv6: resource.ipv6 + } + end +end diff --git a/apps/api/lib/api/gateway/channel.ex b/apps/api/lib/api/gateway/channel.ex index 9d5240a5f..7f58de876 100644 --- a/apps/api/lib/api/gateway/channel.ex +++ b/apps/api/lib/api/gateway/channel.ex @@ -1,16 +1,95 @@ defmodule API.Gateway.Channel do use API, :channel - alias Domain.Gateways + alias API.Gateway.Views + alias Domain.{Clients, Resources, Relays, Gateways} @impl true def join("gateway", _payload, socket) do send(self(), :after_join) + socket = assign(socket, :refs, %{}) {:ok, socket} end @impl true def handle_info(:after_join, socket) do - Gateways.connect_gateway(socket.assigns.gateway, socket) + push(socket, "init", %{ + interface: Views.Interface.render(socket.assigns.gateway), + # TODO: move to settings + ipv4_masquerade_enabled: true, + ipv6_masquerade_enabled: true + }) + + :ok = Gateways.connect_gateway(socket.assigns.gateway) + {:noreply, socket} end + + def handle_info({:request_connection, {channel_pid, socket_ref}, attrs}, socket) do + %{ + client_id: client_id, + resource_id: resource_id, + authorization_expires_at: authorization_expires_at, + client_rtc_session_description: rtc_session_description, + client_preshared_key: preshared_key + } = attrs + + client = Clients.fetch_client_by_id!(client_id, preload: [:actor]) + resource = Resources.fetch_resource_by_id!(resource_id) + {:ok, relays} = Relays.list_connected_relays_for_resource(resource) + + ref = Ecto.UUID.generate() + + push(socket, "request_connection", %{ + ref: ref, + actor: Views.Actor.render(client.actor), + relays: Views.Relay.render_many(relays, authorization_expires_at), + resource: Views.Resource.render(resource), + client: Views.Client.render(client, rtc_session_description, preshared_key), + expires_at: DateTime.to_unix(authorization_expires_at, :second) + }) + + refs = Map.put(socket.assigns.refs, ref, {channel_pid, socket_ref, resource_id}) + socket = assign(socket, :refs, refs) + + {:noreply, socket} + end + + @impl true + def handle_in( + "connection_ready", + %{ + "ref" => ref, + "gateway_rtc_session_description" => rtc_session_description + }, + socket + ) do + {{channel_pid, socket_ref, resource_id}, refs} = Map.pop(socket.assigns.refs, ref) + socket = assign(socket, :refs, refs) + + send( + channel_pid, + {:connect, socket_ref, resource_id, socket.assigns.gateway.public_key, + rtc_session_description} + ) + + {:reply, :ok, socket} + end + + # def handle_in("metrics", params, socket) do + # %{ + # "started_at" => started_at, + # "ended_at" => ended_at, + # "metrics" => [ + # %{ + # "client_id" => client_id, + # "resource_id" => resource_id, + # "rx_bytes" => 0, + # "tx_packets" => 0 + # } + # ] + # } + + # :ok = Gateways.update_metrics(socket.assigns.relay, metrics) + # {:noreply, socket} + # end end diff --git a/apps/api/lib/api/gateway/socket.ex b/apps/api/lib/api/gateway/socket.ex index 354c0bbce..f93dde965 100644 --- a/apps/api/lib/api/gateway/socket.ex +++ b/apps/api/lib/api/gateway/socket.ex @@ -52,5 +52,6 @@ defmodule API.Gateway.Socket do end @impl true - def id(socket), do: "gateway:#{socket.assigns.gateway.id}" + def id(%Gateways.Gateway{} = gateway), do: "gateway:#{gateway.id}" + def id(socket), do: id(socket.assigns.gateway) end diff --git a/apps/api/lib/api/gateway/views/actor.ex b/apps/api/lib/api/gateway/views/actor.ex new file mode 100644 index 000000000..29af56644 --- /dev/null +++ b/apps/api/lib/api/gateway/views/actor.ex @@ -0,0 +1,9 @@ +defmodule API.Gateway.Views.Actor do + alias Domain.Actors + + def render(%Actors.Actor{} = actor) do + %{ + id: actor.id + } + end +end diff --git a/apps/api/lib/api/gateway/views/client.ex b/apps/api/lib/api/gateway/views/client.ex new file mode 100644 index 000000000..34cf2bd97 --- /dev/null +++ b/apps/api/lib/api/gateway/views/client.ex @@ -0,0 +1,17 @@ +defmodule API.Gateway.Views.Client do + alias Domain.Clients + + def render(%Clients.Client{} = client, client_rtc_session_description, preshared_key) do + %{ + id: client.id, + rtc_session_description: client_rtc_session_description, + peer: %{ + persistent_keepalive: 25, + public_key: client.public_key, + preshared_key: preshared_key, + ipv4: client.ipv4, + ipv6: client.ipv6 + } + } + end +end diff --git a/apps/api/lib/api/gateway/views/interface.ex b/apps/api/lib/api/gateway/views/interface.ex new file mode 100644 index 000000000..2673c1847 --- /dev/null +++ b/apps/api/lib/api/gateway/views/interface.ex @@ -0,0 +1,10 @@ +defmodule API.Gateway.Views.Interface do + alias Domain.Gateways + + def render(%Gateways.Gateway{} = gateway) do + %{ + ipv4: gateway.ipv4, + ipv6: gateway.ipv6 + } + end +end diff --git a/apps/api/lib/api/gateway/views/relay.ex b/apps/api/lib/api/gateway/views/relay.ex new file mode 100644 index 000000000..49e4ab650 --- /dev/null +++ b/apps/api/lib/api/gateway/views/relay.ex @@ -0,0 +1,5 @@ +defmodule API.Gateway.Views.Relay do + def render_many(relays, expires_at) do + Enum.flat_map(relays, &API.Client.Views.Relay.render(&1, expires_at)) + end +end diff --git a/apps/api/lib/api/gateway/views/resource.ex b/apps/api/lib/api/gateway/views/resource.ex new file mode 100644 index 000000000..21bf94666 --- /dev/null +++ b/apps/api/lib/api/gateway/views/resource.ex @@ -0,0 +1,12 @@ +defmodule API.Gateway.Views.Resource do + alias Domain.Resources + + def render(%Resources.Resource{} = resource) do + %{ + id: resource.id, + address: resource.address, + ipv4: resource.ipv4, + ipv6: resource.ipv6 + } + end +end diff --git a/apps/api/lib/api/relay/channel.ex b/apps/api/lib/api/relay/channel.ex index f2cc2301c..7eafade26 100644 --- a/apps/api/lib/api/relay/channel.ex +++ b/apps/api/lib/api/relay/channel.ex @@ -10,7 +10,8 @@ defmodule API.Relay.Channel do @impl true def handle_info({:after_join, stamp_secret}, socket) do - :ok = Relays.connect_relay(socket.assigns.relay, stamp_secret, socket) + push(socket, "init", %{}) + :ok = Relays.connect_relay(socket.assigns.relay, stamp_secret) {:noreply, socket} end end diff --git a/apps/api/lib/api/relay/socket.ex b/apps/api/lib/api/relay/socket.ex index 8dcfbde3d..86473bada 100644 --- a/apps/api/lib/api/relay/socket.ex +++ b/apps/api/lib/api/relay/socket.ex @@ -52,5 +52,6 @@ defmodule API.Relay.Socket do end @impl true - def id(socket), do: "relay:#{socket.assigns.relay.id}" + def id(%Relays.Relay{} = relay), do: "relay:#{relay.id}" + def id(socket), do: id(socket.assigns.relay) end diff --git a/apps/api/test/api/client/channel_test.exs b/apps/api/test/api/client/channel_test.exs index 723ad00c4..779478b27 100644 --- a/apps/api/test/api/client/channel_test.exs +++ b/apps/api/test/api/client/channel_test.exs @@ -1,26 +1,240 @@ defmodule API.Client.ChannelTest do use API.ChannelCase - alias Domain.ClientsFixtures + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, ResourcesFixtures} + alias Domain.{ClientsFixtures, RelaysFixtures, GatewaysFixtures} setup do - client = ClientsFixtures.create_client() + account = AccountsFixtures.create_account() + actor = ActorsFixtures.create_actor(role: :admin, account: account) + identity = AuthFixtures.create_identity(actor: actor, account: account) + subject = AuthFixtures.create_subject(identity) + client = ClientsFixtures.create_client(subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account) + + resource = + ResourcesFixtures.create_resource( + account: account, + gateways: [%{gateway_id: gateway.id}] + ) + + expires_at = DateTime.utc_now() |> DateTime.add(30, :second) {:ok, _reply, socket} = API.Client.Socket - |> socket("client:#{client.id}", %{client: client}) + |> socket("client:#{client.id}", %{ + client: client, + subject: subject, + expires_at: expires_at + }) |> subscribe_and_join(API.Client.Channel, "client") - %{client: client, socket: socket} + %{ + account: account, + actor: actor, + identity: identity, + subject: subject, + client: client, + gateway: gateway, + resource: resource, + socket: socket + } end - test "tracks presence after join", %{client: client, socket: socket} do - presence = Domain.Clients.Presence.list(socket) + describe "join/3" do + test "tracks presence after join", %{client: client} do + presence = Domain.Clients.Presence.list("clients") - assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, client.id) - assert is_number(online_at) + assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, client.id) + assert is_number(online_at) + end + + test "expires the channel when token is expired", %{client: client, subject: subject} do + expires_at = DateTime.utc_now() |> DateTime.add(25, :millisecond) + subject = %{subject | expires_at: expires_at} + + {:ok, _reply, _socket} = + API.Client.Socket + |> socket("client:#{client.id}", %{ + client: client, + subject: subject + }) + |> subscribe_and_join(API.Client.Channel, "client") + + assert_push "token_expired", %{}, 250 + end + + test "sends list of resources after join", %{ + client: client, + resource: resource + } do + assert_push "init", %{resources: resources, interface: interface} + + assert resources == [ + %{ + address: resource.address, + id: resource.id, + ipv4: resource.ipv4, + ipv6: resource.ipv6 + } + ] + + assert interface == %{ + ipv4: client.ipv4, + ipv6: client.ipv6, + upstream_dns: [ + %Postgrex.INET{address: {1, 1, 1, 1}, netmask: nil}, + %Postgrex.INET{address: {1, 0, 0, 1}, netmask: nil} + ] + } + end end - test "sends list of resources after join" do - assert_push "resources", %{resources: []} + describe "handle_in/3 list_relays" do + test "returns error when resource is not found", %{socket: socket} do + ref = push(socket, "list_relays", %{"resource_id" => Ecto.UUID.generate()}) + assert_reply ref, :error, :not_found + end + + test "returns error when there are no online relays", %{resource: resource, socket: socket} do + ref = push(socket, "list_relays", %{"resource_id" => resource.id}) + assert_reply ref, :error, :offline + end + + test "returns list of online relays", %{account: account, resource: resource, socket: socket} do + relay = RelaysFixtures.create_relay(account: account) + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(relay, stamp_secret) + + ref = push(socket, "list_relays", %{"resource_id" => resource.id}) + assert_reply ref, :ok, %{relays: relays} + + ipv4_stun_uri = "stun:#{relay.ipv4}:#{relay.port}" + ipv4_turn_uri = "turn:#{relay.ipv4}:#{relay.port}" + ipv6_stun_uri = "stun:#{relay.ipv6}:#{relay.port}" + ipv6_turn_uri = "turn:#{relay.ipv6}:#{relay.port}" + + assert [ + %{ + type: :stun, + uri: ^ipv4_stun_uri + }, + %{ + type: :turn, + expires_at: expires_at_unix, + password: password1, + username: username1, + uri: ^ipv4_turn_uri + }, + %{ + type: :stun, + uri: ^ipv6_stun_uri + }, + %{ + type: :turn, + expires_at: expires_at_unix, + password: password2, + username: username2, + uri: ^ipv6_turn_uri + } + ] = relays + + assert username1 != username2 + assert password1 != password2 + + assert [expires_at, salt] = String.split(username1, ":", parts: 2) + expires_at = expires_at |> String.to_integer() |> DateTime.from_unix!() + socket_expires_at = DateTime.truncate(socket.assigns.expires_at, :second) + assert expires_at == socket_expires_at + + assert is_binary(salt) + end + end + + describe "handle_in/3 request_connection" do + test "returns error when resource is not found", %{socket: socket} do + attrs = %{ + "resource_id" => Ecto.UUID.generate(), + "client_rtc_session_description" => "RTC_SD", + "client_preshared_key" => "PSK" + } + + ref = push(socket, "request_connection", attrs) + assert_reply ref, :error, :not_found + end + + test "returns error when all gateways are offline", %{ + resource: resource, + socket: socket + } do + attrs = %{ + "resource_id" => resource.id, + "client_rtc_session_description" => "RTC_SD", + "client_preshared_key" => "PSK" + } + + ref = push(socket, "request_connection", attrs) + assert_reply ref, :error, :offline + end + + test "returns error when all gateways connected to the resource are offline", %{ + account: account, + resource: resource, + socket: socket + } do + attrs = %{ + "resource_id" => resource.id, + "client_rtc_session_description" => "RTC_SD", + "client_preshared_key" => "PSK" + } + + gateway = GatewaysFixtures.create_gateway(account: account) + :ok = Domain.Gateways.connect_gateway(gateway) + + ref = push(socket, "request_connection", attrs) + assert_reply ref, :error, :offline + end + + test "broadcasts request_connection to the gateways and then returns connect message", %{ + resource: resource, + gateway: gateway, + client: client, + socket: socket + } do + public_key = gateway.public_key + resource_id = resource.id + client_id = client.id + + :ok = Domain.Gateways.connect_gateway(gateway) + Phoenix.PubSub.subscribe(Domain.PubSub, API.Gateway.Socket.id(gateway)) + + attrs = %{ + "resource_id" => resource.id, + "client_rtc_session_description" => "RTC_SD", + "client_preshared_key" => "PSK" + } + + ref = push(socket, "request_connection", attrs) + + assert_receive {:request_connection, {channel_pid, socket_ref}, payload} + + assert %{ + resource_id: ^resource_id, + client_id: ^client_id, + client_preshared_key: "PSK", + client_rtc_session_description: "RTC_SD", + authorization_expires_at: authorization_expires_at + } = payload + + assert authorization_expires_at == socket.assigns.expires_at + + send(channel_pid, {:connect, socket_ref, resource.id, gateway.public_key, "FULL_RTC_SD"}) + + assert_reply ref, :ok, %{ + resource_id: ^resource_id, + persistent_keepalive: 25, + gateway_public_key: ^public_key, + gateway_rtc_session_description: "FULL_RTC_SD" + } + end end end diff --git a/apps/api/test/api/client/socket_test.exs b/apps/api/test/api/client/socket_test.exs index fd2d93700..986ce56ba 100644 --- a/apps/api/test/api/client/socket_test.exs +++ b/apps/api/test/api/client/socket_test.exs @@ -51,7 +51,7 @@ defmodule API.Client.SocketTest do describe "id/1" do test "creates a channel for a client" do - client = %{id: Ecto.UUID.generate()} + client = ClientsFixtures.create_client() socket = socket(API.Client.Socket, "", %{client: client}) assert id(socket) == "client:#{client.id}" diff --git a/apps/api/test/api/gateway/channel_test.exs b/apps/api/test/api/gateway/channel_test.exs index cd9731093..b0f334d51 100644 --- a/apps/api/test/api/gateway/channel_test.exs +++ b/apps/api/test/api/gateway/channel_test.exs @@ -1,22 +1,203 @@ defmodule API.Gateway.ChannelTest do use API.ChannelCase - alias Domain.GatewaysFixtures + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, ResourcesFixtures} + alias Domain.{ClientsFixtures, RelaysFixtures, GatewaysFixtures} setup do - gateway = GatewaysFixtures.create_gateway() + account = AccountsFixtures.create_account() + actor = ActorsFixtures.create_actor(role: :admin, account: account) + identity = AuthFixtures.create_identity(actor: actor, account: account) + subject = AuthFixtures.create_subject(identity) + client = ClientsFixtures.create_client(subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account) + + resource = + ResourcesFixtures.create_resource( + account: account, + gateways: [%{gateway_id: gateway.id}] + ) {:ok, _, socket} = API.Gateway.Socket |> socket("gateway:#{gateway.id}", %{gateway: gateway}) |> subscribe_and_join(API.Gateway.Channel, "gateway") - %{gateway: gateway, socket: socket} + relay = RelaysFixtures.create_relay(account: account) + + %{ + account: account, + actor: actor, + identity: identity, + subject: subject, + client: client, + gateway: gateway, + resource: resource, + relay: relay, + socket: socket + } end - test "tracks presence after join", %{gateway: gateway, socket: socket} do - presence = Domain.Gateways.Presence.list(socket) + describe "join/3" do + test "tracks presence after join", %{gateway: gateway} do + presence = Domain.Gateways.Presence.list("gateways") - assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, gateway.id) - assert is_number(online_at) + assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, gateway.id) + assert is_number(online_at) + end + + test "sends list of resources after join", %{ + gateway: gateway + } do + assert_push "init", %{ + interface: interface, + ipv4_masquerade_enabled: true, + ipv6_masquerade_enabled: true + } + + assert interface == %{ + ipv4: gateway.ipv4, + ipv6: gateway.ipv6 + } + end + end + + describe "handle_info/2 :request_connection" do + test "pushes request_connection message", %{ + client: client, + resource: resource, + relay: relay, + socket: socket + } do + channel_pid = self() + socket_ref = make_ref() + expires_at = DateTime.utc_now() |> DateTime.add(30, :second) + preshared_key = "PSK" + rtc_session_description = "RTC_SD" + + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(relay, stamp_secret) + + send( + socket.channel_pid, + {:request_connection, {channel_pid, socket_ref}, + %{ + client_id: client.id, + resource_id: resource.id, + authorization_expires_at: expires_at, + client_rtc_session_description: rtc_session_description, + client_preshared_key: preshared_key + }} + ) + + assert_push "request_connection", payload + + assert is_binary(payload.ref) + assert payload.actor == %{id: client.actor_id} + + ipv4_stun_uri = "stun:#{relay.ipv4}:#{relay.port}" + ipv4_turn_uri = "turn:#{relay.ipv4}:#{relay.port}" + ipv6_stun_uri = "stun:#{relay.ipv6}:#{relay.port}" + ipv6_turn_uri = "turn:#{relay.ipv6}:#{relay.port}" + + assert [ + %{ + type: :stun, + uri: ^ipv4_stun_uri + }, + %{ + type: :turn, + expires_at: expires_at_unix, + password: password1, + username: username1, + uri: ^ipv4_turn_uri + }, + %{ + type: :stun, + uri: ^ipv6_stun_uri + }, + %{ + type: :turn, + expires_at: expires_at_unix, + password: password2, + username: username2, + uri: ^ipv6_turn_uri + } + ] = payload.relays + + assert username1 != username2 + assert password1 != password2 + assert [username_expires_at_unix, username_salt] = String.split(username1, ":", parts: 2) + assert username_expires_at_unix == to_string(DateTime.to_unix(expires_at, :second)) + assert DateTime.from_unix!(expires_at_unix) == DateTime.truncate(expires_at, :second) + assert is_binary(username_salt) + + assert payload.resource == %{ + address: resource.address, + id: resource.id, + ipv4: resource.ipv4, + ipv6: resource.ipv6 + } + + assert payload.client == %{ + id: client.id, + peer: %{ + ipv4: client.ipv4, + ipv6: client.ipv6, + persistent_keepalive: 25, + preshared_key: preshared_key, + public_key: client.public_key + }, + rtc_session_description: rtc_session_description + } + + assert DateTime.from_unix!(payload.expires_at) == DateTime.truncate(expires_at, :second) + end + end + + describe "handle_in/3 connection_ready" do + test "forwards RFC session description to the client channel", %{ + client: client, + resource: resource, + relay: relay, + gateway: gateway, + socket: socket + } do + channel_pid = self() + socket_ref = make_ref() + expires_at = DateTime.utc_now() |> DateTime.add(30, :second) + preshared_key = "PSK" + gateway_public_key = gateway.public_key + rtc_session_description = "RTC_SD" + + stamp_secret = Ecto.UUID.generate() + :ok = Domain.Relays.connect_relay(relay, stamp_secret) + + send( + socket.channel_pid, + {:request_connection, {channel_pid, socket_ref}, + %{ + client_id: client.id, + resource_id: resource.id, + authorization_expires_at: expires_at, + client_rtc_session_description: rtc_session_description, + client_preshared_key: preshared_key + }} + ) + + assert_push "request_connection", %{ref: ref} + + push_ref = + push(socket, "connection_ready", %{ + "ref" => ref, + "gateway_rtc_session_description" => rtc_session_description + }) + + assert_reply push_ref, :ok + + assert_receive {:connect, ^socket_ref, resource_id, ^gateway_public_key, + ^rtc_session_description} + + assert resource_id == resource.id + end end end diff --git a/apps/api/test/api/gateway/socket_test.exs b/apps/api/test/api/gateway/socket_test.exs index 63e5c3d21..61083fefb 100644 --- a/apps/api/test/api/gateway/socket_test.exs +++ b/apps/api/test/api/gateway/socket_test.exs @@ -66,7 +66,7 @@ defmodule API.Gateway.SocketTest do describe "id/1" do test "creates a channel for a gateway" do - gateway = %{id: Ecto.UUID.generate()} + gateway = GatewaysFixtures.create_gateway() socket = socket(API.Gateway.Socket, "", %{gateway: gateway}) assert id(socket) == "gateway:#{gateway.id}" diff --git a/apps/api/test/api/relay/channel_test.exs b/apps/api/test/api/relay/channel_test.exs index b0d4b0f77..b89253388 100644 --- a/apps/api/test/api/relay/channel_test.exs +++ b/apps/api/test/api/relay/channel_test.exs @@ -15,10 +15,16 @@ defmodule API.Relay.ChannelTest do %{relay: relay, socket: socket} end - test "tracks presence after join", %{relay: relay, socket: socket} do - presence = Domain.Relays.Presence.list(socket) + describe "join/3" do + test "tracks presence after join", %{relay: relay} do + presence = Domain.Relays.Presence.list("relays") - assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, relay.id) - assert is_number(online_at) + assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, relay.id) + assert is_number(online_at) + end + + test "sends init message after join" do + assert_push "init", %{} + end end end diff --git a/apps/api/test/api/relay/socket_test.exs b/apps/api/test/api/relay/socket_test.exs index dad565eb5..29dfd434e 100644 --- a/apps/api/test/api/relay/socket_test.exs +++ b/apps/api/test/api/relay/socket_test.exs @@ -66,7 +66,7 @@ defmodule API.Relay.SocketTest do describe "id/1" do test "creates a channel for a relay" do - relay = %{id: Ecto.UUID.generate()} + relay = RelaysFixtures.create_relay() socket = socket(API.Relay.Socket, "", %{relay: relay}) assert id(socket) == "relay:#{relay.id}" diff --git a/apps/domain/lib/domain/actors.ex b/apps/domain/lib/domain/actors.ex index 279d97e68..9db327dbb 100644 --- a/apps/domain/lib/domain/actors.ex +++ b/apps/domain/lib/domain/actors.ex @@ -83,9 +83,9 @@ defmodule Domain.Actors do end) |> Repo.transaction() |> case do - {:ok, %{actor: actor}} -> + {:ok, %{actor: actor, identity: identity}} -> Telemetry.add_actor() - {:ok, actor} + {:ok, %{actor | identities: [identity]}} {:error, _step, changeset, _effects_so_far} -> {:error, changeset} diff --git a/apps/domain/lib/domain/auth.ex b/apps/domain/lib/domain/auth.ex index 296a2454b..2e0cdb727 100644 --- a/apps/domain/lib/domain/auth.ex +++ b/apps/domain/lib/domain/auth.ex @@ -6,6 +6,11 @@ defmodule Domain.Auth do alias Domain.Auth.{Authorizer, Subject, Context, Permission, Roles, Role, Identity} alias Domain.Auth.{Adapters, Provider} + @default_session_duration_hours %{ + admin: 3, + unprivileged: 24 * 7 + } + def start_link(opts) do Supervisor.start_link(__MODULE__, opts, name: __MODULE__) end @@ -95,13 +100,23 @@ defmodule Domain.Auth do |> Repo.fetch!() end - def create_identity(%Actors.Actor{} = actor, %Provider{} = provider, provider_identifier) do + def create_identity( + %Actors.Actor{} = actor, + %Provider{} = provider, + provider_identifier, + provider_attrs \\ %{} + ) do Identity.Changeset.create(actor, provider, provider_identifier) - |> Adapters.identity_changeset(provider) + |> Adapters.identity_changeset(provider, provider_attrs) |> Repo.insert() end - def replace_identity(%Identity{} = identity, provider_identifier, %Subject{} = subject) do + def replace_identity( + %Identity{} = identity, + provider_identifier, + provider_attrs \\ %{}, + %Subject{} = subject + ) do required_permissions = {:one_of, [ @@ -120,7 +135,7 @@ defmodule Domain.Auth do end) |> Ecto.Multi.insert(:new_identity, fn %{identity: identity} -> Identity.Changeset.create(identity.actor, identity.provider, provider_identifier) - |> Adapters.identity_changeset(identity.provider) + |> Adapters.identity_changeset(identity.provider, provider_attrs) end) |> Ecto.Multi.update(:deleted_identity, fn %{identity: identity} -> Identity.Changeset.delete_identity(identity) @@ -156,9 +171,9 @@ defmodule Domain.Auth do def sign_in(%Provider{} = provider, provider_identifier, secret, user_agent, remote_ip) do with {:ok, identity} <- fetch_identity_by_provider_and_identifier(provider, provider_identifier), - {:ok, identity} <- + {:ok, identity, expires_at} <- Adapters.verify_secret(provider, identity, secret) do - {:ok, build_subject(identity, user_agent, remote_ip)} + {:ok, build_subject(identity, expires_at, user_agent, remote_ip)} else {:error, :not_found} -> {:error, :unauthorized} {:error, :invalid_secret} -> {:error, :unauthorized} @@ -168,8 +183,9 @@ defmodule Domain.Auth do def sign_in(session_token, user_agent, remote_ip) do with {:ok, identity_id} <- verify_session_token(session_token, user_agent, remote_ip), + {:ok, expires_at} <- fetch_session_token_expires_at(session_token), {:ok, identity} <- fetch_identity_by_id(identity_id) do - {:ok, build_subject(identity, user_agent, remote_ip)} + {:ok, build_subject(identity, expires_at, user_agent, remote_ip)} else {:error, :not_found} -> {:error, :unauthorized} {:error, :invalid_token} -> {:error, :unauthorized} @@ -184,8 +200,9 @@ defmodule Domain.Auth do |> Repo.fetch() end - defp build_subject(%Identity{} = identity, user_agent, remote_ip) - when is_binary(user_agent) and is_tuple(remote_ip) do + @doc false + def build_subject(%Identity{} = identity, expires_at, user_agent, remote_ip) + when is_binary(user_agent) and is_tuple(remote_ip) do identity = identity |> Identity.Changeset.sign_in(user_agent, remote_ip) @@ -199,19 +216,46 @@ defmodule Domain.Auth do actor: identity_with_preloads.actor, permissions: permissions, account: identity_with_preloads.account, + expires_at: build_subject_expires_at(identity_with_preloads.actor, expires_at), context: %Context{remote_ip: remote_ip, user_agent: user_agent} } end + defp build_subject_expires_at(%Actors.Actor{} = actor, expires_at) do + default_session_duration_hours = Map.fetch!(@default_session_duration_hours, actor.role) + expires_at || DateTime.utc_now() |> DateTime.add(default_session_duration_hours, :hour) + end + # Session - # TODO: we need to leverage provider token expiration here def create_session_token_from_subject(%Subject{} = subject) do config = fetch_config!() key_base = Keyword.fetch!(config, :key_base) salt = Keyword.fetch!(config, :salt) payload = session_token_payload(subject) - {:ok, Plug.Crypto.sign(key_base, salt, payload)} + max_age = DateTime.diff(subject.expires_at, DateTime.utc_now(), :second) + + {:ok, Plug.Crypto.sign(key_base, salt, payload, max_age: max_age)} + end + + def fetch_session_token_expires_at(token, opts \\ []) do + config = fetch_config!() + key_base = Keyword.fetch!(config, :key_base) + salt = Keyword.fetch!(config, :salt) + + iterations = Keyword.get(opts, :key_iterations, 1000) + length = Keyword.get(opts, :key_length, 32) + digest = Keyword.get(opts, :key_digest, :sha256) + cache = Keyword.get(opts, :cache, Plug.Crypto.Keys) + secret = Plug.Crypto.KeyGenerator.generate(key_base, salt, iterations, length, digest, cache) + + with {:ok, message} <- Plug.Crypto.MessageVerifier.verify(token, secret) do + {_data, signed, max_age} = Plug.Crypto.non_executable_binary_to_term(message) + {:ok, datetime} = DateTime.from_unix(signed + trunc(max_age * 1000), :millisecond) + {:ok, datetime} + else + :error -> {:error, :invalid_token} + end end defp session_token_payload(%Subject{identity: %Identity{} = identity, context: context}), @@ -226,11 +270,10 @@ defmodule Domain.Auth do config = fetch_config!() key_base = Keyword.fetch!(config, :key_base) salt = Keyword.fetch!(config, :salt) - max_age = Keyword.fetch!(config, :max_age) context_payload = session_context_payload(remote_ip, user_agent) - case Plug.Crypto.verify(key_base, salt, token, max_age: max_age) do + case Plug.Crypto.verify(key_base, salt, token) do {:ok, {:identity, identity_id, ^context_payload}} -> {:ok, identity_id} @@ -299,51 +342,4 @@ defmodule Domain.Auth do missing_permissions -> {:error, {:unauthorized, missing_permissions: missing_permissions}} end end - - ############ - - def fetch_oidc_provider_config(provider_id) do - with {:ok, provider} <- fetch_provider(:openid_connect_providers, provider_id) do - redirect_uri = - if provider.redirect_uri do - provider.redirect_uri - else - external_url = Domain.Config.fetch_env!(:web, :external_url) - "#{external_url}auth/oidc/#{provider.id}/callback/" - end - - {:ok, - %{ - discovery_document_uri: provider.discovery_document_uri, - client_id: provider.client_id, - client_secret: provider.client_secret, - redirect_uri: redirect_uri, - response_type: provider.response_type, - scope: provider.scope - }} - end - end - - def auto_create_users?(field, provider_id) do - fetch_provider!(field, provider_id).auto_create_users - end - - defp fetch_provider(field, provider_id) do - Config.fetch_config!(field) - |> Enum.find(&(&1.id == provider_id)) - |> case do - nil -> {:error, :not_found} - provider -> {:ok, provider} - end - end - - defp fetch_provider!(field, provider_id) do - case fetch_provider(field, provider_id) do - {:ok, provider} -> - provider - - {:error, :not_found} -> - raise RuntimeError, "Unknown provider #{provider_id}" - end - end end diff --git a/apps/domain/lib/domain/auth/adapter.ex b/apps/domain/lib/domain/auth/adapter.ex index 4ee4518d3..9574984ec 100644 --- a/apps/domain/lib/domain/auth/adapter.ex +++ b/apps/domain/lib/domain/auth/adapter.ex @@ -30,7 +30,7 @@ defmodule Domain.Auth.Adapter do if it's valid, or an error otherwise. """ @callback verify_secret(%Identity{}, secret :: term()) :: - {:ok, %Identity{}} + {:ok, %Identity{}, expires_at :: %DateTime{} | nil} | {:error, :invalid_secret} | {:error, :expired_secret} | {:error, :internal_error} diff --git a/apps/domain/lib/domain/auth/adapters.ex b/apps/domain/lib/domain/auth/adapters.ex index da9619e73..c78dceddc 100644 --- a/apps/domain/lib/domain/auth/adapters.ex +++ b/apps/domain/lib/domain/auth/adapters.ex @@ -4,7 +4,8 @@ defmodule Domain.Auth.Adapters do @adapters %{ email: Domain.Auth.Adapters.Email, - openid_connect: Domain.Auth.Adapters.OpenIDConnect + openid_connect: Domain.Auth.Adapters.OpenIDConnect, + userpass: Domain.Auth.Adapters.UserPass } @adapter_names Map.keys(@adapters) @@ -18,8 +19,9 @@ defmodule Domain.Auth.Adapters do Supervisor.init(@adapter_modules, strategy: :one_for_one) end - def identity_changeset(%Ecto.Changeset{} = changeset, %Provider{} = provider) do + def identity_changeset(%Ecto.Changeset{} = changeset, %Provider{} = provider, provider_attrs) do adapter = fetch_adapter!(provider) + changeset = Ecto.Changeset.put_change(changeset, :provider_virtual_state, provider_attrs) %Ecto.Changeset{} = adapter.identity_changeset(provider, changeset) end @@ -42,7 +44,7 @@ defmodule Domain.Auth.Adapters do adapter = fetch_adapter!(provider) case adapter.verify_secret(identity, secret) do - {:ok, %Identity{} = identity} -> {:ok, identity} + {:ok, %Identity{} = identity, expires_at} -> {:ok, identity, expires_at} {:error, :invalid_secret} -> {:error, :invalid_secret} {:error, :expired_secret} -> {:error, :expired_secret} {:error, :internal_error} -> {:error, :internal_error} diff --git a/apps/domain/lib/domain/auth/adapters/email.ex b/apps/domain/lib/domain/auth/adapters/email.ex index 5ca8c61e7..bd80a285a 100644 --- a/apps/domain/lib/domain/auth/adapters/email.ex +++ b/apps/domain/lib/domain/auth/adapters/email.ex @@ -77,8 +77,13 @@ defmodule Domain.Auth.Adapters.Email do Identity.Query.by_id(identity.id) |> Repo.fetch_and_update( with: fn identity -> - sign_in_token_hash = identity.provider_state["sign_in_token_hash"] - sign_in_token_created_at = identity.provider_state["sign_in_token_created_at"] + sign_in_token_hash = + identity.provider_state["sign_in_token_hash"] || + identity.provider_state[:sign_in_token_hash] + + sign_in_token_created_at = + identity.provider_state["sign_in_token_created_at"] || + identity.provider_state[:sign_in_token_created_at] cond do is_nil(sign_in_token_hash) -> @@ -98,6 +103,10 @@ defmodule Domain.Auth.Adapters.Email do end end ) + |> case do + {:ok, identity} -> {:ok, identity, nil} + {:error, reason} -> {:error, reason} + end end defp sign_in_token_expired?(sign_in_token_created_at) do diff --git a/apps/domain/lib/domain/auth/adapters/openid_connect.ex b/apps/domain/lib/domain/auth/adapters/openid_connect.ex index 7640ea493..3ad12ad67 100644 --- a/apps/domain/lib/domain/auth/adapters/openid_connect.ex +++ b/apps/domain/lib/domain/auth/adapters/openid_connect.ex @@ -19,13 +19,11 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do end @impl true - def identity_changeset(%Provider{} = provider, %Ecto.Changeset{} = changeset) do - {state, virtual_state} = identity_create_state(provider) - + def identity_changeset(%Provider{} = _provider, %Ecto.Changeset{} = changeset) do changeset |> Domain.Validator.trim_change(:provider_identifier) - |> Ecto.Changeset.put_change(:provider_state, state) - |> Ecto.Changeset.put_change(:provider_virtual_state, virtual_state) + |> Ecto.Changeset.put_change(:provider_state, %{}) + |> Ecto.Changeset.put_change(:provider_virtual_state, %{}) end @impl true @@ -44,10 +42,6 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do changeset end - defp identity_create_state(%Provider{} = _provider) do - {%{}, %{}} - end - def authorization_uri(%Provider{} = provider, redirect_uri) when is_binary(redirect_uri) do config = config_for_provider(provider) @@ -82,6 +76,12 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do code: code, code_verifier: code_verifier }) + |> case do + {:ok, identity, expires_at} -> {:ok, identity, expires_at} + {:error, :expired_token} -> {:error, :expired_secret} + {:error, :invalid_token} -> {:error, :invalid_secret} + {:error, :internal_error} -> {:error, :internal_error} + end end def refresh_token(%Identity{} = identity) do @@ -98,6 +98,18 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do {:ok, claims} <- OpenIDConnect.verify(config, tokens["id_token"]), {:ok, userinfo} <- OpenIDConnect.fetch_userinfo(config, tokens["id_token"]) do # TODO: sync groups + expires_at = + cond do + not is_nil(tokens["expires_in"]) -> + DateTime.add(DateTime.utc_now(), tokens["expires_in"], :second) + + not is_nil(claims["exp"]) -> + DateTime.from_unix!(claims["exp"]) + + true -> + nil + end + Identity.Query.by_id(identity.id) |> Repo.fetch_and_update( with: fn identity -> @@ -105,15 +117,16 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do id_token: tokens["id_token"], access_token: tokens["access_token"], refresh_token: tokens["refresh_token"], - expires_at: - if(tokens["expires_in"], - do: DateTime.add(DateTime.utc_now(), tokens["expires_in"], :second) - ), + expires_at: expires_at, userinfo: userinfo, claims: claims }) end ) + |> case do + {:ok, identity} -> {:ok, identity, expires_at} + {:error, reason} -> {:error, reason} + end else {:error, {:invalid_jwt, "invalid exp claim: token has expired"}} -> {:error, :expired_token} diff --git a/apps/domain/lib/domain/auth/adapters/userpass.ex b/apps/domain/lib/domain/auth/adapters/userpass.ex index e5726bb5f..480754082 100644 --- a/apps/domain/lib/domain/auth/adapters/userpass.ex +++ b/apps/domain/lib/domain/auth/adapters/userpass.ex @@ -1,2 +1,94 @@ -defmodule Domain.Auth.Providers.UserPass do +defmodule Domain.Auth.Adapters.UserPass do + @moduledoc """ + This is not recommended to use in production, + it's only for development, testing, and small home labs. + """ + use Supervisor + alias Domain.Repo + alias Domain.Auth.{Identity, Provider, Adapter} + alias Domain.Auth.Adapters.UserPass.Password + + @behaviour Adapter + + def start_link(_init_arg) do + Supervisor.start_link(__MODULE__, nil, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + children = [] + + Supervisor.init(children, strategy: :one_for_one) + end + + @impl true + def identity_changeset(%Provider{} = _provider, %Ecto.Changeset{} = changeset) do + changeset + |> Domain.Validator.trim_change(:provider_identifier) + |> validate_password() + end + + defp validate_password(changeset) do + data = Map.get(changeset.data, :provider_virtual_state) || %{} + attrs = Ecto.Changeset.get_change(changeset, :provider_virtual_state) || %{} + + Ecto.embedded_load(Password, data, :json) + |> Password.Changeset.changeset(attrs) + |> case do + %{valid?: false} = nested_changeset -> + {changeset, _original_type} = + Domain.Changeset.inject_embedded_changeset( + changeset, + :provider_virtual_state, + nested_changeset + ) + + changeset + + %{valid?: true} = nested_changeset -> + password_hash = Ecto.Changeset.fetch_change!(nested_changeset, :password_hash) + + changeset + |> Ecto.Changeset.put_change(:provider_state, %{password_hash: password_hash}) + |> Ecto.Changeset.put_change(:provider_virtual_state, %{}) + end + end + + @impl true + def ensure_provisioned(%Ecto.Changeset{} = changeset) do + changeset + end + + @impl true + def ensure_deprovisioned(%Ecto.Changeset{} = changeset) do + changeset + end + + @impl true + def verify_secret(%Identity{} = identity, password) when is_binary(password) do + Identity.Query.by_id(identity.id) + |> Repo.fetch_and_update( + with: fn identity -> + password_hash = identity.provider_state["password_hash"] + + cond do + is_nil(password_hash) -> + :invalid_secret + + not Domain.Crypto.equal?(password, password_hash) -> + :invalid_secret + + true -> + Ecto.Changeset.change(identity) + end + end + ) + |> case do + {:ok, identity} -> + {:ok, identity, nil} + + {:error, reason} -> + {:error, reason} + end + end end diff --git a/apps/domain/lib/domain/auth/adapters/userpass/changeset.ex b/apps/domain/lib/domain/auth/adapters/userpass/changeset.ex deleted file mode 100644 index a8772b9d5..000000000 --- a/apps/domain/lib/domain/auth/adapters/userpass/changeset.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule Domain.Auth.Adapters.UserPass.Changeset do - # @min_password_length 12 - # @max_password_length 64 - - # defp change_password_changeset(%Ecto.Changeset{} = changeset) do - # changeset - # |> validate_required([:password]) - # |> validate_confirmation(:password, required: true) - # |> validate_length(:password, min: @min_password_length, max: @max_password_length) - # |> put_hash(:password, to: :password_hash) - # |> redact_field(:password) - # |> redact_field(:password_confirmation) - # |> validate_required([:password_hash]) - # end -end diff --git a/apps/domain/lib/domain/auth/adapters/userpass/password.ex b/apps/domain/lib/domain/auth/adapters/userpass/password.ex new file mode 100644 index 000000000..f36679bfc --- /dev/null +++ b/apps/domain/lib/domain/auth/adapters/userpass/password.ex @@ -0,0 +1,10 @@ +defmodule Domain.Auth.Adapters.UserPass.Password do + use Domain, :schema + + @primary_key false + embedded_schema do + field :password, :string, virtual: true, redact: true + field :password_hash, :string + field :password_confirmation, :string, virtual: true, redact: true + end +end diff --git a/apps/domain/lib/domain/auth/adapters/userpass/password/changeset.ex b/apps/domain/lib/domain/auth/adapters/userpass/password/changeset.ex new file mode 100644 index 000000000..a8a245dd8 --- /dev/null +++ b/apps/domain/lib/domain/auth/adapters/userpass/password/changeset.ex @@ -0,0 +1,24 @@ +defmodule Domain.Auth.Adapters.UserPass.Password.Changeset do + use Domain, :changeset + alias Domain.Auth.Adapters.UserPass.Password + + @fields ~w[password]a + @min_password_length 12 + @max_password_length 64 + + def create_changeset(attrs) do + changeset(%Password{}, attrs) + end + + def changeset(struct, attrs) do + struct + |> cast(attrs, @fields) + |> validate_required(@fields) + |> validate_confirmation(:password, required: true) + |> validate_length(:password, min: @min_password_length, max: @max_password_length) + |> put_hash(:password, to: :password_hash) + |> redact_field(:password) + |> redact_field(:password_confirmation) + |> validate_required([:password_hash]) + end +end diff --git a/apps/domain/lib/domain/auth/identities.ex b/apps/domain/lib/domain/auth/identities.ex deleted file mode 100644 index 6d6c5c2a7..000000000 --- a/apps/domain/lib/domain/auth/identities.ex +++ /dev/null @@ -1,2 +0,0 @@ -defmodule Domain.Auth.Identities do -end diff --git a/apps/domain/lib/domain/auth/identity.ex b/apps/domain/lib/domain/auth/identity.ex index 2b1f885fb..820fc4177 100644 --- a/apps/domain/lib/domain/auth/identity.ex +++ b/apps/domain/lib/domain/auth/identity.ex @@ -7,7 +7,7 @@ defmodule Domain.Auth.Identity do field :provider_identifier, :string field :provider_state, :map - field :provider_virtual_state, :map, virtual: true, redact: true + field :provider_virtual_state, :map, virtual: true field :last_seen_user_agent, :string field :last_seen_remote_ip, Domain.Types.IP diff --git a/apps/domain/lib/domain/auth/provider.ex b/apps/domain/lib/domain/auth/provider.ex index 0d8805fb4..a322bd4d1 100644 --- a/apps/domain/lib/domain/auth/provider.ex +++ b/apps/domain/lib/domain/auth/provider.ex @@ -4,7 +4,7 @@ defmodule Domain.Auth.Provider do schema "auth_providers" do field :name, :string - field :adapter, Ecto.Enum, values: ~w[email openid_connect]a + field :adapter, Ecto.Enum, values: ~w[email openid_connect userpass]a field :adapter_config, :map belongs_to :account, Domain.Accounts.Account diff --git a/apps/domain/lib/domain/auth/providers.ex b/apps/domain/lib/domain/auth/providers.ex deleted file mode 100644 index fffe88b0a..000000000 --- a/apps/domain/lib/domain/auth/providers.ex +++ /dev/null @@ -1,2 +0,0 @@ -defmodule Domain.Auth.Providers do -end diff --git a/apps/domain/lib/domain/auth/roles.ex b/apps/domain/lib/domain/auth/roles.ex index c0268e34b..6b51c7be4 100644 --- a/apps/domain/lib/domain/auth/roles.ex +++ b/apps/domain/lib/domain/auth/roles.ex @@ -16,7 +16,8 @@ defmodule Domain.Auth.Roles do Domain.Clients.Authorizer, Domain.Gateways.Authorizer, Domain.Relays.Authorizer, - Domain.Actors.Authorizer + Domain.Actors.Authorizer, + Domain.Resources.Authorizer ] end diff --git a/apps/domain/lib/domain/auth/subject.ex b/apps/domain/lib/domain/auth/subject.ex index fa1884738..2f798c081 100644 --- a/apps/domain/lib/domain/auth/subject.ex +++ b/apps/domain/lib/domain/auth/subject.ex @@ -6,21 +6,20 @@ defmodule Domain.Auth.Subject do @type actor :: %Actors.Actor{} @type permission :: Permission.t() - # TODO: we need to add subject expiration retrieved from IdP provider, - # so that when we exchange subject for a token we keep the expiration - # preventing session extension @type t :: %__MODULE__{ identity: identity(), actor: actor(), permissions: MapSet.t(permission), account: %Domain.Accounts.Account{}, + expires_at: DateTime.t(), context: Context.t() } - @enforce_keys [:identity, :actor, :permissions, :account, :context] + @enforce_keys [:identity, :actor, :permissions, :account, :context, :expires_at] defstruct identity: nil, actor: nil, permissions: MapSet.new(), account: nil, + expires_at: nil, context: %Context{} end diff --git a/apps/domain/lib/domain/changeset.ex b/apps/domain/lib/domain/changeset.ex index dc7ba3c79..f9d1bad3d 100644 --- a/apps/domain/lib/domain/changeset.ex +++ b/apps/domain/lib/domain/changeset.ex @@ -54,7 +54,7 @@ defmodule Domain.Changeset do end end - defp inject_embedded_changeset(changeset, field, nested_changeset) do + def inject_embedded_changeset(changeset, field, nested_changeset) do original_type = Map.get(changeset.types, field) embedded_type = diff --git a/apps/domain/lib/domain/clients.ex b/apps/domain/lib/domain/clients.ex index 6a62c60a8..a9d9b0e53 100644 --- a/apps/domain/lib/domain/clients.ex +++ b/apps/domain/lib/domain/clients.ex @@ -157,9 +157,11 @@ defmodule Domain.Clients do Auth.ensure_has_permissions(subject, Authorizer.manage_clients_permission()) end - def connect_client(%Client{} = client, socket) do + def connect_client(%Client{} = client) do + Phoenix.PubSub.subscribe(Domain.PubSub, "actor:#{client.actor_id}") + {:ok, _} = - Presence.track(socket, client.id, %{ + Presence.track(self(), "clients", client.id, %{ online_at: System.system_time(:second) }) diff --git a/apps/domain/lib/domain/config/configuration.ex b/apps/domain/lib/domain/config/configuration.ex index af603035a..bd38b3d36 100644 --- a/apps/domain/lib/domain/config/configuration.ex +++ b/apps/domain/lib/domain/config/configuration.ex @@ -3,6 +3,8 @@ defmodule Domain.Config.Configuration do alias Domain.Config.Logo schema "configurations" do + # field :upstream_dns, {:array, :string}, default: [] + field :allow_unprivileged_device_management, :boolean field :allow_unprivileged_device_configuration, :boolean diff --git a/apps/domain/lib/domain/gateways.ex b/apps/domain/lib/domain/gateways.ex index c0048bb34..5e55e04ab 100644 --- a/apps/domain/lib/domain/gateways.ex +++ b/apps/domain/lib/domain/gateways.ex @@ -1,6 +1,7 @@ defmodule Domain.Gateways do use Supervisor alias Domain.{Repo, Auth, Validator} + alias Domain.Resources alias Domain.Gateways.{Authorizer, Gateway, Group, Token, Presence} def start_link(opts) do @@ -128,6 +129,24 @@ defmodule Domain.Gateways do end end + def list_connected_gateways_for_resource(%Resources.Resource{} = resource) do + connected_gateways = Presence.list("gateways") + + gateways = + connected_gateways + |> Map.keys() + # XXX: This will create a pretty large query to send to Postgres, + # we probably want to load connected resources once when gateway connects, + # and persist them in the memory not to query DB every time with a + # `WHERE ... IN (...)`. + |> Gateway.Query.by_ids() + |> Gateway.Query.by_account_id(resource.account_id) + |> Gateway.Query.by_resource_id(resource.id) + |> Repo.all() + + {:ok, gateways} + end + def change_gateway(%Gateway{} = gateway, attrs \\ %{}) do Gateway.Changeset.update_changeset(gateway, attrs) end @@ -180,9 +199,9 @@ defmodule Domain.Gateways do end end - def connect_gateway(%Gateway{} = gateway, socket) do + def connect_gateway(%Gateway{} = gateway) do {:ok, _} = - Presence.track(socket, gateway.id, %{ + Presence.track(self(), "gateways", gateway.id, %{ online_at: System.system_time(:second) }) diff --git a/apps/domain/lib/domain/gateways/gateway.ex b/apps/domain/lib/domain/gateways/gateway.ex index bece0860f..95e4a5d5d 100644 --- a/apps/domain/lib/domain/gateways/gateway.ex +++ b/apps/domain/lib/domain/gateways/gateway.ex @@ -20,6 +20,8 @@ defmodule Domain.Gateways.Gateway do belongs_to :group, Domain.Gateways.Group belongs_to :token, Domain.Gateways.Token + has_many :connections, Domain.Resources.Connection + field :deleted_at, :utc_datetime_usec timestamps() end diff --git a/apps/domain/lib/domain/gateways/gateway/query.ex b/apps/domain/lib/domain/gateways/gateway/query.ex index 9ff60a0c3..352de4256 100644 --- a/apps/domain/lib/domain/gateways/gateway/query.ex +++ b/apps/domain/lib/domain/gateways/gateway/query.ex @@ -10,6 +10,10 @@ defmodule Domain.Gateways.Gateway.Query do where(queryable, [gateways: gateways], gateways.id == ^id) end + def by_ids(queryable \\ all(), ids) do + where(queryable, [gateways: gateways], gateways.id in ^ids) + end + def by_user_id(queryable \\ all(), user_id) do where(queryable, [gateways: gateways], gateways.user_id == ^user_id) end @@ -18,10 +22,23 @@ defmodule Domain.Gateways.Gateway.Query do where(queryable, [gateways: gateways], gateways.account_id == ^account_id) end + def by_resource_id(queryable \\ all(), resource_id) do + queryable + |> with_joined_connections() + |> where([connections: connections], connections.resource_id == ^resource_id) + end + def returning_all(queryable \\ all()) do select(queryable, [gateways: gateways], gateways) end + def with_joined_connections(queryable \\ all()) do + with_named_binding(queryable, :connections, fn queryable, binding -> + queryable + |> join(:inner, [gateways: gateways], connections in assoc(gateways, ^binding), as: ^binding) + end) + end + def with_preloaded_user(queryable \\ all()) do with_named_binding(queryable, :user, fn queryable, binding -> queryable diff --git a/apps/domain/lib/domain/gateways/token/changeset.ex b/apps/domain/lib/domain/gateways/token/changeset.ex index 8261f85be..f8eb04b7d 100644 --- a/apps/domain/lib/domain/gateways/token/changeset.ex +++ b/apps/domain/lib/domain/gateways/token/changeset.ex @@ -7,7 +7,7 @@ defmodule Domain.Gateways.Token.Changeset do %Gateways.Token{} |> change() |> put_change(:account_id, account.id) - |> put_change(:value, Domain.Crypto.rand_string()) + |> put_change(:value, Domain.Crypto.rand_string(64)) |> put_hash(:value, to: :hash) |> assoc_constraint(:group) |> check_constraint(:hash, name: :hash_not_null, message: "can't be blank") diff --git a/apps/domain/lib/domain/relays.ex b/apps/domain/lib/domain/relays.ex index 9e3decb04..bdd6affa4 100644 --- a/apps/domain/lib/domain/relays.ex +++ b/apps/domain/lib/domain/relays.ex @@ -1,6 +1,7 @@ defmodule Domain.Relays do use Supervisor alias Domain.{Repo, Auth, Validator} + alias Domain.Resources alias Domain.Relays.{Authorizer, Relay, Group, Token, Presence} def start_link(opts) do @@ -128,6 +129,36 @@ defmodule Domain.Relays do end end + def list_connected_relays_for_resource(%Resources.Resource{} = resource) do + connected_relays = Presence.list("relays") + + relays = + connected_relays + |> Map.keys() + |> Relay.Query.by_ids() + |> Relay.Query.by_account_id(resource.account_id) + |> Repo.all() + |> Enum.map(fn relay -> + %{metas: [%{secret: stamp_secret}]} = Map.get(connected_relays, relay.id) + %{relay | stamp_secret: stamp_secret} + end) + + {:ok, relays} + end + + def generate_username_and_password(%Relay{stamp_secret: stamp_secret}, %DateTime{} = expires_at) + when is_binary(stamp_secret) do + expires_at = DateTime.to_unix(expires_at, :second) + salt = Domain.Crypto.rand_string() + password = generate_hash(expires_at, stamp_secret, salt) + %{username: "#{expires_at}:#{salt}", password: password, expires_at: expires_at} + end + + defp generate_hash(expires_at, stamp_secret, salt) do + :crypto.hash(:sha256, "#{expires_at}:#{stamp_secret}:#{salt}") + |> Base.encode64(padding: false) + end + def upsert_relay(%Token{} = token, attrs) do changeset = Relay.Changeset.upsert_changeset(token, attrs) @@ -152,9 +183,9 @@ defmodule Domain.Relays do end end - def connect_relay(%Relay{} = relay, secret, socket) do + def connect_relay(%Relay{} = relay, secret) do {:ok, _} = - Presence.track(socket, relay.id, %{ + Presence.track(self(), "relays", relay.id, %{ online_at: System.system_time(:second), secret: secret }) diff --git a/apps/domain/lib/domain/relays/relay.ex b/apps/domain/lib/domain/relays/relay.ex index 3e3134765..4ec8150d8 100644 --- a/apps/domain/lib/domain/relays/relay.ex +++ b/apps/domain/lib/domain/relays/relay.ex @@ -5,13 +5,15 @@ defmodule Domain.Relays.Relay do field :ipv4, Domain.Types.IP field :ipv6, Domain.Types.IP - # TODO: port field + field :port, :integer, default: 3478 field :last_seen_user_agent, :string field :last_seen_remote_ip, Domain.Types.IP field :last_seen_version, :string field :last_seen_at, :utc_datetime_usec + field :stamp_secret, :string, virtual: true + belongs_to :account, Domain.Accounts.Account belongs_to :group, Domain.Relays.Group belongs_to :token, Domain.Relays.Token diff --git a/apps/domain/lib/domain/relays/relay/changeset.ex b/apps/domain/lib/domain/relays/relay/changeset.ex index 490df75ce..c39ebb189 100644 --- a/apps/domain/lib/domain/relays/relay/changeset.ex +++ b/apps/domain/lib/domain/relays/relay/changeset.ex @@ -3,9 +3,9 @@ defmodule Domain.Relays.Relay.Changeset do alias Domain.Version alias Domain.Relays - @upsert_fields ~w[ipv4 ipv6 + @upsert_fields ~w[ipv4 ipv6 port last_seen_user_agent last_seen_remote_ip]a - @conflict_replace_fields ~w[ipv4 ipv6 + @conflict_replace_fields ~w[ipv4 ipv6 port last_seen_user_agent last_seen_remote_ip last_seen_version last_seen_at]a @@ -19,6 +19,7 @@ defmodule Domain.Relays.Relay.Changeset do |> cast(attrs, @upsert_fields) |> validate_required(~w[last_seen_user_agent last_seen_remote_ip]a) |> validate_required_one_of(~w[ipv4 ipv6]a) + |> validate_number(:port, greater_than_or_equal_to: 1, less_than_or_equal_to: 65_535) |> unique_constraint(:ipv4, name: :relays_account_id_ipv4_index) |> unique_constraint(:ipv6, name: :relays_account_id_ipv6_index) |> put_change(:last_seen_at, DateTime.utc_now()) diff --git a/apps/domain/lib/domain/relays/relay/query.ex b/apps/domain/lib/domain/relays/relay/query.ex index 5f05c01c7..287d81f30 100644 --- a/apps/domain/lib/domain/relays/relay/query.ex +++ b/apps/domain/lib/domain/relays/relay/query.ex @@ -10,12 +10,20 @@ defmodule Domain.Relays.Relay.Query do where(queryable, [relays: relays], relays.id == ^id) end + def by_ids(queryable \\ all(), ids) do + where(queryable, [relays: relays], relays.id in ^ids) + end + def by_user_id(queryable \\ all(), user_id) do where(queryable, [relays: relays], relays.user_id == ^user_id) end def by_account_id(queryable \\ all(), account_id) do - where(queryable, [relays: relays], relays.account_id == ^account_id) + where( + queryable, + [relays: relays], + relays.account_id == ^account_id or is_nil(relays.account_id) + ) end def returning_all(queryable \\ all()) do diff --git a/apps/domain/lib/domain/relays/token/changeset.ex b/apps/domain/lib/domain/relays/token/changeset.ex index 8f3fe3127..86484d73c 100644 --- a/apps/domain/lib/domain/relays/token/changeset.ex +++ b/apps/domain/lib/domain/relays/token/changeset.ex @@ -6,7 +6,7 @@ defmodule Domain.Relays.Token.Changeset do def create_changeset(%Accounts.Account{} = account) do %Relays.Token{} |> change() - |> put_change(:value, Domain.Crypto.rand_string()) + |> put_change(:value, Domain.Crypto.rand_string(64)) |> put_hash(:value, to: :hash) |> assoc_constraint(:group) |> check_constraint(:hash, name: :hash_not_null, message: "can't be blank") diff --git a/apps/domain/lib/domain/resources.ex b/apps/domain/lib/domain/resources.ex new file mode 100644 index 000000000..8d899763e --- /dev/null +++ b/apps/domain/lib/domain/resources.ex @@ -0,0 +1,117 @@ +defmodule Domain.Resources do + alias Domain.{Repo, Validator, Auth} + alias Domain.Resources.{Authorizer, Resource} + + def fetch_resource_by_id(id, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()), + true <- Validator.valid_uuid?(id) do + Resource.Query.by_id(id) + |> Authorizer.for_subject(subject) + |> Repo.fetch() + else + false -> {:error, :not_found} + other -> other + end + end + + def fetch_resource_by_id!(id) do + if Validator.valid_uuid?(id) do + Resource.Query.by_id(id) + |> Repo.one!() + else + {:error, :not_found} + end + end + + def list_resources(%Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do + Resource.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + end + end + + def create_resource(attrs, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do + changeset = Resource.Changeset.create_changeset(subject.account, attrs) + + Ecto.Multi.new() + |> Ecto.Multi.insert(:resource, changeset, returning: true) + |> resolve_address_multi(:ipv4) + |> resolve_address_multi(:ipv6) + |> Ecto.Multi.update(:resource_with_address, fn + %{resource: %Resource{} = resource, ipv4: ipv4, ipv6: ipv6} -> + Resource.Changeset.finalize_create_changeset(resource, ipv4, ipv6) + end) + |> Repo.transaction() + |> case do + {:ok, %{resource_with_address: resource}} -> + # TODO: Add optimistic lock to resource.updated_at to serialize the resource updates + # TODO: Broadcast only to actors that have access to the resource + # {:ok, actors} = list_authorized_actors(resource) + # Phoenix.PubSub.broadcast( + # Domain.PubSub, + # "actor_client:#{subject.actor.id}", + # {:resource_added, resource.id} + # ) + + {:ok, resource} + + {:error, :resource, changeset, _effects_so_far} -> + {:error, changeset} + end + end + end + + defp resolve_address_multi(multi, type) do + Ecto.Multi.run(multi, type, fn _repo, %{resource: %Resource{} = resource} -> + if address = Map.get(resource, type) do + {:ok, address} + else + {:ok, Domain.Network.fetch_next_available_address!(resource.account_id, type)} + end + end) + end + + def update_resource(%Resource{} = resource, attrs, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do + resource + |> Resource.Changeset.update_changeset(attrs) + |> Repo.update() + |> case do + {:ok, resource} -> + # Phoenix.PubSub.broadcast( + # Domain.PubSub, + # "actor_client:#{resource.actor_id}", + # {:resource_updated, resource.id} + # ) + + {:ok, resource} + + {:error, reason} -> + {:error, reason} + end + end + end + + def delete_resource(%Resource{} = resource, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do + Resource.Query.by_id(resource.id) + |> Authorizer.for_subject(subject) + |> Repo.fetch_and_update(with: &Resource.Changeset.delete_changeset/1) + |> case do + {:ok, resource} -> + # Phoenix.PubSub.broadcast( + # Domain.PubSub, + # "actor_client:#{resource.actor_id}", + # {:resource_removed, resource.id} + # ) + + {:ok, resource} + + {:error, reason} -> + {:error, reason} + end + end + end +end diff --git a/apps/domain/lib/domain/resources/authorizer.ex b/apps/domain/lib/domain/resources/authorizer.ex new file mode 100644 index 000000000..b5f12c74c --- /dev/null +++ b/apps/domain/lib/domain/resources/authorizer.ex @@ -0,0 +1,25 @@ +defmodule Domain.Resources.Authorizer do + use Domain.Auth.Authorizer + alias Domain.Resources.Resource + + def manage_resources_permission, do: build(Resource, :manage) + + @impl Domain.Auth.Authorizer + def list_permissions_for_role(:admin) do + [ + manage_resources_permission() + ] + end + + def list_permissions_for_role(_) do + [] + end + + @impl Domain.Auth.Authorizer + def for_subject(queryable, %Subject{} = subject) do + cond do + has_permission?(subject, manage_resources_permission()) -> + Resource.Query.by_account_id(queryable, subject.account.id) + end + end +end diff --git a/apps/domain/lib/domain/resources/connection.ex b/apps/domain/lib/domain/resources/connection.ex new file mode 100644 index 000000000..7b30469f0 --- /dev/null +++ b/apps/domain/lib/domain/resources/connection.ex @@ -0,0 +1,11 @@ +defmodule Domain.Resources.Connection do + use Domain, :schema + + @primary_key false + schema "resource_connections" do + belongs_to :resource, Domain.Resources.Resource, primary_key: true + belongs_to :gateway, Domain.Gateways.Gateway, primary_key: true + + belongs_to :account, Domain.Accounts.Account + end +end diff --git a/apps/domain/lib/domain/resources/connection/changeset.ex b/apps/domain/lib/domain/resources/connection/changeset.ex new file mode 100644 index 000000000..64f75a4ab --- /dev/null +++ b/apps/domain/lib/domain/resources/connection/changeset.ex @@ -0,0 +1,16 @@ +defmodule Domain.Resources.Connection.Changeset do + use Domain, :changeset + + @fields ~w[gateway_id]a + @required_fields @fields + + def changeset(account_id, connection, attrs) do + connection + |> cast(attrs, @fields) + |> validate_required(@required_fields) + |> assoc_constraint(:resource) + |> assoc_constraint(:gateway) + |> assoc_constraint(:account) + |> put_change(:account_id, account_id) + end +end diff --git a/apps/domain/lib/domain/resources/connection/query.ex b/apps/domain/lib/domain/resources/connection/query.ex new file mode 100644 index 000000000..f89e0ba08 --- /dev/null +++ b/apps/domain/lib/domain/resources/connection/query.ex @@ -0,0 +1,11 @@ +defmodule Domain.Resources.Connection.Query do + use Domain, :query + + def all do + from(connections in Domain.Resources.Connection, as: :connections) + end + + def by_account_id(queryable \\ all(), account_id) do + where(queryable, [connections: connections], connections.account_id == ^account_id) + end +end diff --git a/apps/domain/lib/domain/resources/resource.ex b/apps/domain/lib/domain/resources/resource.ex new file mode 100644 index 000000000..b6266dc85 --- /dev/null +++ b/apps/domain/lib/domain/resources/resource.ex @@ -0,0 +1,23 @@ +defmodule Domain.Resources.Resource do + use Domain, :schema + + schema "resources" do + field :address, :string + field :name, :string + + embeds_many :filters, Filter, on_replace: :delete do + field :protocol, Ecto.Enum, values: [tcp: 6, udp: 17, icmp: 1, all: -1] + field :ports, {:array, Domain.Types.Int4Range}, default: [] + end + + field :ipv4, Domain.Types.IP + field :ipv6, Domain.Types.IP + + belongs_to :account, Domain.Accounts.Account + has_many :connections, Domain.Resources.Connection, on_replace: :delete + has_many :gateways, through: [:connections, :gateway] + + field :deleted_at, :utc_datetime_usec + timestamps() + end +end diff --git a/apps/domain/lib/domain/resources/resource/changeset.ex b/apps/domain/lib/domain/resources/resource/changeset.ex new file mode 100644 index 000000000..364503a13 --- /dev/null +++ b/apps/domain/lib/domain/resources/resource/changeset.ex @@ -0,0 +1,63 @@ +defmodule Domain.Resources.Resource.Changeset do + use Domain, :changeset + alias Domain.Accounts + alias Domain.Resources.{Resource, Connection} + + @fields ~w[address name]a + @update_fields ~w[name]a + @required_fields ~w[address]a + + def create_changeset(%Accounts.Account{} = account, attrs) do + %Resource{} + |> cast(attrs, @fields) + |> validate_required(@required_fields) + |> changeset() + |> put_change(:account_id, account.id) + |> cast_assoc(:connections, + with: &Connection.Changeset.changeset(account.id, &1, &2), + required: true + ) + end + + def finalize_create_changeset(%Resource{} = resource, ipv4, ipv6) do + resource + |> change() + |> put_change(:ipv4, ipv4) + |> put_change(:ipv6, ipv6) + |> unique_constraint(:ipv4, name: :resources_account_id_ipv4_index) + |> unique_constraint(:ipv6, name: :resources_account_id_ipv6_index) + end + + def update_changeset(%Resource{} = resource, attrs) do + resource + |> cast(attrs, @update_fields) + |> validate_required(@required_fields) + |> changeset() + |> cast_assoc(:connections, + with: &Connection.Changeset.changeset(resource.account_id, &1, &2), + required: true + ) + end + + defp changeset(changeset) do + changeset + |> put_default_value(:name, from: :address) + |> validate_length(:name, min: 1, max: 255) + |> unique_constraint(:address, name: :resources_account_id_address_index) + |> unique_constraint(:name, name: :resources_account_id_name_index) + |> cast_embed(:filters, with: &cast_filter/2) + |> unique_constraint(:ipv4, name: :resources_account_id_ipv4_index) + |> unique_constraint(:ipv6, name: :resources_account_id_ipv6_index) + end + + def delete_changeset(%Resource{} = resource) do + resource + |> change() + |> put_default_value(:deleted_at, DateTime.utc_now()) + end + + defp cast_filter(%Resource.Filter{} = filter, attrs) do + filter + |> cast(attrs, [:protocol, :ports]) + end +end diff --git a/apps/domain/lib/domain/resources/resource/query.ex b/apps/domain/lib/domain/resources/resource/query.ex new file mode 100644 index 000000000..a6f74db8e --- /dev/null +++ b/apps/domain/lib/domain/resources/resource/query.ex @@ -0,0 +1,16 @@ +defmodule Domain.Resources.Resource.Query do + use Domain, :query + + def all do + from(resources in Domain.Resources.Resource, as: :resources) + |> where([resources: resources], is_nil(resources.deleted_at)) + end + + def by_id(queryable \\ all(), id) do + where(queryable, [resources: resources], resources.id == ^id) + end + + def by_account_id(queryable \\ all(), account_id) do + where(queryable, [resources: resources], resources.account_id == ^account_id) + end +end diff --git a/apps/domain/lib/domain/types/int4range.ex b/apps/domain/lib/domain/types/int4range.ex index 7a3b465a6..3d6dc50bc 100644 --- a/apps/domain/lib/domain/types/int4range.ex +++ b/apps/domain/lib/domain/types/int4range.ex @@ -27,10 +27,15 @@ defmodule Domain.Types.Int4Range do end end + def cast(num) when is_number(num) do + {:ok, Integer.to_string(num)} + end + def cast([num, num]) when is_number(num) do {:ok, Integer.to_string(num)} end + def cast(lower..upper) when upper >= lower, do: {:ok, "#{lower} - #{upper}"} def cast([lower, upper]) when upper >= lower, do: {:ok, "#{lower} - #{upper}"} def cast([_, _]), do: {:error, message: @cast_error} diff --git a/apps/domain/lib/domain/validator.ex b/apps/domain/lib/domain/validator.ex index 96973f9c8..07d1c17f1 100644 --- a/apps/domain/lib/domain/validator.ex +++ b/apps/domain/lib/domain/validator.ex @@ -331,6 +331,13 @@ defmodule Domain.Validator do changeset end + def put_default_value(changeset, field, from: source_field) do + case fetch_field(changeset, source_field) do + {_data_or_changes, value} -> put_default_value(changeset, field, value) + :error -> changeset + end + end + def put_default_value(changeset, field, value) do case fetch_field(changeset, field) do {:data, nil} -> put_change(changeset, field, maybe_apply(changeset, value)) diff --git a/apps/domain/mix.exs b/apps/domain/mix.exs index 76d678584..a89462c12 100644 --- a/apps/domain/mix.exs +++ b/apps/domain/mix.exs @@ -34,7 +34,8 @@ defmodule Domain.MixProject do mod: {Domain.Application, []}, extra_applications: [ :logger, - :runtime_tools + :runtime_tools, + :crypto ] ] end diff --git a/apps/domain/priv/repo/migrations/20230405182924_create_relays.exs b/apps/domain/priv/repo/migrations/20230405182924_create_relays.exs index 9055bd298..9e5fdce93 100644 --- a/apps/domain/priv/repo/migrations/20230405182924_create_relays.exs +++ b/apps/domain/priv/repo/migrations/20230405182924_create_relays.exs @@ -8,6 +8,8 @@ defmodule Domain.Repo.Migrations.CreateRelays do add(:ipv4, :inet) add(:ipv6, :inet) + add(:port, :integer, null: false) + add(:last_seen_user_agent, :string, null: false) add(:last_seen_remote_ip, :inet, null: false) add(:last_seen_version, :string, null: false) diff --git a/apps/domain/priv/repo/migrations/20230512201206_create_resources_and_connections.exs b/apps/domain/priv/repo/migrations/20230512201206_create_resources_and_connections.exs new file mode 100644 index 000000000..085da8f70 --- /dev/null +++ b/apps/domain/priv/repo/migrations/20230512201206_create_resources_and_connections.exs @@ -0,0 +1,68 @@ +defmodule Domain.Repo.Migrations.CreateResourcesAndConnections do + use Ecto.Migration + + def change do + create table(:resources, primary_key: false) do + add(:id, :uuid, primary_key: true) + + add(:address, :string, null: false) + add(:name, :string, null: false) + + add(:filters, :map, default: fragment("'[]'::jsonb"), null: false) + + add( + :ipv4, + references(:network_addresses, + column: :address, + type: :inet, + with: [account_id: :account_id] + ) + ) + + add( + :ipv6, + references(:network_addresses, + column: :address, + type: :inet, + with: [account_id: :account_id] + ) + ) + + add(:account_id, references(:accounts, type: :binary_id), null: false) + + add(:deleted_at, :utc_datetime_usec) + timestamps(type: :utc_datetime_usec) + end + + create(index(:resources, [:account_id, :ipv4], unique: true, where: "deleted_at IS NULL")) + create(index(:resources, [:account_id, :ipv6], unique: true, where: "deleted_at IS NULL")) + + create( + index(:resources, [:account_id, :name], + unique: true, + where: "deleted_at IS NULL" + ) + ) + + create( + index(:resources, [:account_id, :address], + unique: true, + where: "deleted_at IS NULL" + ) + ) + + create table(:resource_connections, primary_key: false) do + add(:resource_id, references(:resources, type: :binary_id, on_delete: :delete_all), + primary_key: true, + null: false + ) + + add(:gateway_id, references(:gateways, type: :binary_id, on_delete: :delete_all), + primary_key: true, + null: false + ) + + add(:account_id, references(:accounts, type: :binary_id), null: false) + end + end +end diff --git a/apps/domain/priv/repo/seeds.exs b/apps/domain/priv/repo/seeds.exs index aea4655e6..4568c730f 100644 --- a/apps/domain/priv/repo/seeds.exs +++ b/apps/domain/priv/repo/seeds.exs @@ -1,246 +1,139 @@ -# Script for populating the database. You can run it as: -# -# mix run priv/repo/seeds.exs -# -# Inside the script, you can read and write to any of your -# repositories directly: -# -# Domain.Repo.insert!(%Domain.SomeSchema{}) -# -# We recommend using the bang functions (`insert!`, `update!` -# and so on) as they will fail if something goes wrong. +alias Domain.{Repo, Accounts, Auth, Actors, Relays, Gateways, Resources} -# alias Domain.{ -# Repo, -# ConnectivityChecks, -# Devices, -# Users, -# ApiTokens, -# Rules, -# Auth.MFA -# } +{:ok, account} = Accounts.create_account(%{name: "Firezone Account"}) +{:ok, _account} = Accounts.create_account(%{name: "Other Corp Account"}) -# create_device = fn user, attrs -> -# Devices.Device.Changeset.create_changeset(user, attrs) -# |> Devices.Device.Changeset.configure_changeset(attrs) -# |> Repo.insert() -# end +{:ok, email_provider} = + Auth.create_provider(account, %{ + name: "email", + adapter: :email, + adapter_config: %{} + }) -# {:ok, unprivileged_user1} = -# Users.create_user(:unprivileged, %{ -# email: "firezone-unprivileged-1@localhost" -# }) +{:ok, _oidc_provider} = + Auth.create_provider(account, %{ + name: "Vault", + adapter: :openid_connect, + adapter_config: %{ + "client_id" => "CLIENT_ID", + "client_secret" => "CLIENT_SECRET", + "response_type" => "code", + "scope" => "openid email offline_access", + "discovery_document_uri" => "https://common.auth0.com/.well-known/openid-configuration" + } + }) -# {:ok, _device} = -# create_device.(unprivileged_user1, %{ -# name: "My Device", -# description: "foo bar", -# preshared_key: "27eCDMVRVFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=", -# public_key: "4Fo+SBnDJ6hi8qzPt3nWLwgjCVwvpjHL35qJeatKwEc=", -# remote_ip: %Postgrex.INET{address: {127, 5, 0, 1}}, -# dns: ["8.8.8.8", "8.8.4.4"], -# allowed_ips: [ -# %Postgrex.INET{address: {0, 0, 0, 0}, netmask: 0}, -# %Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 0}, netmask: 0}, -# %Postgrex.INET{address: {1, 1, 1, 1}} -# ], -# use_default_allowed_ips: false, -# use_default_dns: false, -# rx_bytes: 123_917_823, -# tx_bytes: 1_934_475_211_087_234 -# }) +{:ok, userpass_provider} = + Auth.create_provider(account, %{ + name: "UserPass", + adapter: :userpass, + adapter_config: %{} + }) -# {:ok, mfa_user} = -# Users.create_user(:unprivileged, %{ -# email: "firezone-mfa@localhost", -# password: "firezone1234", -# password_confirmation: "firezone1234" -# }) +unprivileged_actor_email = "firezone-unprivileged-1@localhost" +admin_actor_email = "firezone@localhost" -# secret = NimbleTOTP.secret() +{:ok, unprivileged_actor} = + Actors.create_actor(email_provider, unprivileged_actor_email, %{ + type: :user, + role: :unprivileged + }) -# MFA.create_method( -# %{ -# name: "Google Authenticator", -# type: :totp, -# payload: %{"secret" => Base.encode64(secret)}, -# code: NimbleTOTP.verification_code(secret) -# }, -# mfa_user.id -# ) +{:ok, admin_actor} = + Actors.create_actor(email_provider, admin_actor_email, %{ + type: :user, + role: :admin + }) -# {:ok, user} = -# Users.create_user(:admin, %{ -# email: "firezone@localhost", -# password: "firezone1234", -# password_confirmation: "firezone1234" -# }) +{:ok, _unprivileged_actor_userpass_identity} = + Auth.create_identity(unprivileged_actor, userpass_provider, unprivileged_actor_email, %{ + "password" => "Firezone1234", + "password_confirmation" => "Firezone1234" + }) -# {:ok, _api_token} = ApiTokens.create_api_token(user, %{"expires_in" => 5}) -# {:ok, _api_token} = ApiTokens.create_api_token(user, %{"expires_in" => 30}) -# {:ok, _api_token} = ApiTokens.create_api_token(user, %{"expires_in" => 1}) +{:ok, _admin_actor_userpass_identity} = + Auth.create_identity(admin_actor, userpass_provider, admin_actor_email, %{ + "password" => "Firezone1234", + "password_confirmation" => "Firezone1234" + }) -# {:ok, _device} = -# create_device.(user, %{ -# name: "wireguard-client", -# description: """ -# Test device corresponding to the client configuration used in the wireguard-client container -# """, -# preshared_key: "C+Tte1echarIObr6rq+nFeYQ1QO5xo5N29ygDjMlpS8=", -# public_key: "pSLWbPiQ2mKh26IG1dMFQQWuAstFJXV91dNk+olzEjA=", -# mtu: 1280, -# persistent_keepalive: 25, -# allowed_ips: [ -# %Postgrex.INET{address: {0, 0, 0, 0}, netmask: 0}, -# %Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 0}, netmask: 0} -# ], -# endpoint: "elixir", -# dns: ["127.0.0.11"], -# use_default_allowed_ips: false, -# use_default_dns: false, -# use_default_endpoint: false, -# use_default_mtu: false, -# use_default_persistent_keepalive: false -# }) +unprivileged_actor_token = hd(unprivileged_actor.identities).provider_virtual_state.sign_in_token +admin_actor_token = hd(admin_actor.identities).provider_virtual_state.sign_in_token -# {:ok, _device} = -# create_device.(user, %{ -# name: "Factory Device 3", -# description: "foo 3", -# preshared_key: "23eCDMVRVFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=", -# public_key: "3Fo+SBnDJ6hi8q4Pt3nWLwgjCVwvpjHL35qJeatKwEc=", -# remote_ip: %Postgrex.INET{address: {127, 1, 0, 1}}, -# rx_bytes: 123_917_823, -# tx_bytes: 1_934_475_211_087_234 -# }) +admin_subject = + Auth.build_subject( + hd(admin_actor.identities), + nil, + "iOS/12.5 (iPhone) connlib/0.7.412", + {100, 64, 100, 58} + ) -# {:ok, _device} = -# create_device.(user, %{ -# name: "Factory Device 5", -# description: "foo 3", -# preshared_key: "23eCDMVRbFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=", -# public_key: "3Fo+SBnDJ6hb8q4Pt3nWLwgjCVwvpjHL35qJeatKwEc=", -# remote_ip: %Postgrex.INET{address: {127, 3, 0, 1}}, -# rx_bytes: 123_917_823, -# tx_bytes: 1_934_475_211_087_234 -# }) +IO.puts("Created users: ") -# {:ok, _device} = -# create_device.(user, %{ -# name: "Factory Device 4", -# description: "foo 3", -# preshared_key: "2yeCDMVRVFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=", -# public_key: "3Fo+nBnDJ6hi8q4Pt3nWLwgjCVwvpjHL35qJeatKwEc=", -# remote_ip: %Postgrex.INET{address: {127, 4, 0, 1}}, -# rx_bytes: 123_917_823, -# tx_bytes: 1_934_475_211_087_234 -# }) +for {role, login, password, email_token} <- [ + {:unprivileged, unprivileged_actor_email, "Firezone1234", unprivileged_actor_token}, + {:admin, admin_actor_email, "Firezone1234", admin_actor_token} + ] do + IO.puts(" #{login}, #{role}, password: #{password}, email token: #{email_token}") +end -# {:ok, user} = -# Users.create_user(:admin, %{ -# email: "firezone2@localhost", -# password: "firezone1234", -# password_confirmation: "firezone1234" -# }) +IO.puts("") -# {:ok, _device} = -# create_device.(user, %{ -# name: "Factory Device 2", -# description: "foo 2", -# preshared_key: "27eCDMVRVFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=", -# public_key: "3Fo+SBnDJ6hi8qzPt3nWLwgjCVwvpjHL35qJeatKwEc=", -# remote_ip: %Postgrex.INET{address: {127, 5, 0, 1}}, -# rx_bytes: 123_917_823, -# tx_bytes: 1_934_475_211_087_234 -# }) +relay_group = + account + |> Relays.Group.Changeset.create_changeset(%{name: "mycorp-aws-relays", tokens: [%{}]}) + |> Repo.insert!() -# {:ok, _device} = -# create_device.(user, %{ -# name: "Factory Device", -# description: """ -# Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. A\ -# enean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus\ -# mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat ma\ -# ssa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim ju\ -# sto, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pret\ -# ium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifen\ -# d tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem \ -# ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius la\ -# oreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorpe\ -# r ultricies nisi. Nam eget dui. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aen\ -# ean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis partu\ -# rient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, preti\ -# um quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, \ -# vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam \ -# dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum sempe\ -# r nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, e\ -# leifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus \ -# viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi\ -# vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Lorem ipsum dolor sit amet, c\ -# onsectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoq\ -# ue penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultri\ -# cies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede jus\ -# to\ -# """, -# preshared_key: "27eCDMVvVFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=", -# public_key: "3Fo+SNnDJ6hi8qzPt3nWLwgjCVwvpjHL35qJeatKwEc=", -# remote_ip: %Postgrex.INET{address: {127, 6, 0, 1}}, -# rx_bytes: 123_917_823, -# tx_bytes: 1_934_475_211_087_234 -# }) +IO.puts("Created relay groups:") +IO.puts(" #{relay_group.name} token: #{hd(relay_group.tokens).value}") +IO.puts("") -# {:ok, _connectivity_check} = -# ConnectivityChecks.ConnectivityCheck.Changeset.create_changeset(%{ -# response_headers: %{"Content-Type" => "text/plain"}, -# response_body: "127.0.0.1", -# response_code: 200, -# url: "https://ping-dev.firez.one/0.1.19" -# }) -# |> Repo.insert() +gateway_group = + account + |> Gateways.Group.Changeset.create_changeset(%{name_prefix: "mycro-aws-gws", tokens: [%{}]}) + |> Repo.insert!() -# {:ok, _connectivity_check} = -# ConnectivityChecks.ConnectivityCheck.Changeset.create_changeset(%{ -# response_headers: %{"Content-Type" => "text/plain"}, -# response_body: "127.0.0.1", -# response_code: 400, -# url: "https://ping-dev.firez.one/0.20.0" -# }) -# |> Repo.insert() +IO.puts("Created gateway groups:") +IO.puts(" #{gateway_group.name_prefix} token: #{hd(gateway_group.tokens).value}") +IO.puts("") -# Rules.create_rule(%{ -# destination: "10.0.0.0/24", -# port_type: :tcp, -# port_range: "100-200" -# }) +{:ok, gateway} = + Gateways.upsert_gateway(hd(gateway_group.tokens), %{ + external_id: Ecto.UUID.generate(), + name_suffix: "gw-#{Domain.Crypto.rand_string(5)}", + public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(), + last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412", + last_seen_remote_ip: %Postgrex.INET{address: {189, 172, 73, 153}} + }) -# Rules.create_rule(%{ -# destination: "1.2.3.4" -# }) +IO.puts("Created gateways:") +gateway_name = "#{gateway_group.name_prefix}-#{gateway.name_suffix}" +IO.puts(" #{gateway_name}:") +IO.puts(" External UUID: #{gateway.external_id}") +IO.puts(" Public Key: #{gateway.public_key}") +IO.puts(" IPv4: #{gateway.ipv4} IPv6: #{gateway.ipv6}") +IO.puts("") -# Domain.Config.put_config!(:default_client_dns, ["4.3.2.1", "1.2.3.4"]) +{:ok, dns_resource} = + Resources.create_resource( + %{ + address: "gitlab.mycorp.com", + connections: [%{gateway_id: gateway.id}] + }, + admin_subject + ) -# Domain.Config.put_config!( -# :default_client_allowed_ips, -# [ -# %Postgrex.INET{address: {10, 0, 0, 1}, netmask: 20}, -# %Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 0}, netmask: 0}, -# %Postgrex.INET{address: {1, 1, 1, 1}} -# ] -# ) +{:ok, cidr_resource} = + Resources.create_resource( + %{ + address: "172.172.0.1/16", + connections: [%{gateway_id: gateway.id}] + }, + admin_subject + ) -# Domain.Config.put_config!( -# :openid_connect_providers, -# [ -# %{ -# "id" => "vault", -# "discovery_document_uri" => "https://common.auth0.com/.well-known/openid-configuration", -# "client_id" => "CLIENT_ID", -# "client_secret" => "CLIENT_SECRET", -# "redirect_uri" => "http://localhost:13000/auth/oidc/vault/callback/", -# "response_type" => "code", -# "scope" => "openid email offline_access", -# "label" => "OIDC Vault", -# "auto_create_users" => true -# } -# ] -# ) +IO.puts("Created resources:") + +IO.puts(" #{dns_resource.address} - DNS - #{dns_resource.ipv4} - gateways: #{gateway_name}") +IO.puts(" #{cidr_resource.address} - CIDR - #{cidr_resource.ipv4} - gateways: #{gateway_name}") +IO.puts("") diff --git a/apps/domain/test/domain/actors_test.exs b/apps/domain/test/domain/actors_test.exs index 10b88acd1..e3a104413 100644 --- a/apps/domain/test/domain/actors_test.exs +++ b/apps/domain/test/domain/actors_test.exs @@ -155,6 +155,7 @@ defmodule Domain.ActorsTest do actor: %{id: Ecto.UUID.generate()}, account: %{id: Ecto.UUID.generate()}, context: nil, + expires_at: nil, permissions: MapSet.new() } |> AuthFixtures.set_permissions([ diff --git a/apps/domain/test/domain/auth/adapters/email_test.exs b/apps/domain/test/domain/auth/adapters/email_test.exs index 181f2cb84..60e6d8541 100644 --- a/apps/domain/test/domain/auth/adapters/email_test.exs +++ b/apps/domain/test/domain/auth/adapters/email_test.exs @@ -1,7 +1,56 @@ defmodule Domain.Auth.Adapters.EmailTest do use Domain.DataCase, async: true import Domain.Auth.Adapters.Email - alias Domain.AuthFixtures + alias Domain.Auth + alias Domain.{AccountsFixtures, AuthFixtures} + + describe "identity_changeset/2" do + setup do + account = AccountsFixtures.create_account() + provider = AuthFixtures.create_email_provider(account: account) + changeset = %Auth.Identity{} |> Ecto.Changeset.change() + + %{ + account: account, + provider: provider, + changeset: changeset + } + end + + test "puts default provider state", %{provider: provider, changeset: changeset} do + assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset) + + assert %{ + provider_state: %{ + sign_in_token_created_at: %DateTime{}, + sign_in_token_hash: sign_in_token_hash + }, + provider_virtual_state: %{sign_in_token: sign_in_token} + } = changeset.changes + + assert Domain.Crypto.equal?(sign_in_token, sign_in_token_hash) + end + + test "trims provider identifier", %{provider: provider, changeset: changeset} do + changeset = Ecto.Changeset.put_change(changeset, :provider_identifier, " X ") + assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset) + assert changeset.changes.provider_identifier == "X" + end + end + + describe "ensure_provisioned/1" do + test "returns changeset as is" do + changeset = %Ecto.Changeset{} + assert ensure_provisioned(changeset) == changeset + end + end + + describe "ensure_deprovisioned/1" do + test "returns changeset as is" do + changeset = %Ecto.Changeset{} + assert ensure_deprovisioned(changeset) == changeset + end + end describe "request_sign_in_token/1" do test "returns identity with updated sign-in token" do @@ -22,4 +71,50 @@ defmodule Domain.Auth.Adapters.EmailTest do assert %DateTime{} = sign_in_token_created_at end end + + describe "verify_secret/2" do + setup do + account = AccountsFixtures.create_account() + provider = AuthFixtures.create_email_provider(account: account) + identity = AuthFixtures.create_identity(account: account, provider: provider) + token = identity.provider_virtual_state.sign_in_token + + %{account: account, provider: provider, identity: identity, token: token} + end + + test "removes token after it's used", %{ + identity: identity, + token: token + } do + assert {:ok, identity, nil} = verify_secret(identity, token) + + assert identity.provider_state == %{} + assert identity.provider_virtual_state == %{} + end + + test "returns error when token is expired", %{ + account: account, + provider: provider + } do + forty_seconds_ago = DateTime.utc_now() |> DateTime.add(-1 * 15 * 60 - 1, :second) + + identity = + AuthFixtures.create_identity( + account: account, + provider: provider, + provider_state: %{ + "sign_in_token_hash" => Domain.Crypto.hash("dummy_token"), + "sign_in_token_created_at" => DateTime.to_iso8601(forty_seconds_ago) + } + ) + + assert verify_secret(identity, "dummy_token") == {:error, :expired_secret} + end + + test "returns error when token is invalid", %{ + identity: identity + } do + assert verify_secret(identity, "foo") == {:error, :invalid_secret} + end + end end diff --git a/apps/domain/test/domain/auth/adapters/openid_connect_test.exs b/apps/domain/test/domain/auth/adapters/openid_connect_test.exs index b25f92b71..3d498d15d 100644 --- a/apps/domain/test/domain/auth/adapters/openid_connect_test.exs +++ b/apps/domain/test/domain/auth/adapters/openid_connect_test.exs @@ -8,10 +8,15 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do describe "identity_changeset/2" do setup do account = AccountsFixtures.create_account() - provider = AuthFixtures.create_email_provider(account: account) + + {provider, bypass} = + ConfigFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_openid_connect_provider(account: account) + changeset = %Auth.Identity{} |> Ecto.Changeset.change() %{ + bypass: bypass, account: account, provider: provider, changeset: changeset @@ -155,12 +160,12 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} - assert {:ok, identity} = verify_secret(identity, payload) + assert {:ok, identity, expires_at} = verify_secret(identity, payload) assert identity.provider_state == %{ access_token: nil, claims: claims, - expires_at: nil, + expires_at: expires_at, id_token: token, refresh_token: nil, userinfo: %{ @@ -198,7 +203,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} - assert {:ok, identity} = verify_secret(identity, payload) + assert {:ok, identity, _expires_at} = verify_secret(identity, payload) assert identity.provider_state.id_token == token assert identity.provider_state.access_token == "MY_ACCESS_TOKEN" @@ -221,7 +226,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} - assert verify_secret(identity, payload) == {:error, :expired_token} + assert verify_secret(identity, payload) == {:error, :expired_secret} end test "returns error when token is invalid", %{ @@ -236,7 +241,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} - assert verify_secret(identity, payload) == {:error, :invalid_token} + assert verify_secret(identity, payload) == {:error, :invalid_secret} end test "returns error when provider is down", %{ @@ -283,12 +288,12 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do ConfigFixtures.expect_userinfo(bypass) - assert {:ok, identity} = refresh_token(identity) + assert {:ok, identity, expires_at} = refresh_token(identity) assert identity.provider_state == %{ access_token: "MY_ACCESS_TOKEN", claims: claims, - expires_at: nil, + expires_at: expires_at, id_token: token, refresh_token: "MY_REFRESH_TOKEN", userinfo: %{ @@ -303,6 +308,8 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do "sub" => "353690423699814251281" } } + + assert DateTime.diff(expires_at, DateTime.utc_now()) in 5..15 end end diff --git a/apps/domain/test/domain/auth/adapters/userpass_test.exs b/apps/domain/test/domain/auth/adapters/userpass_test.exs new file mode 100644 index 000000000..b38516969 --- /dev/null +++ b/apps/domain/test/domain/auth/adapters/userpass_test.exs @@ -0,0 +1,141 @@ +defmodule Domain.Auth.Adapters.UserPassTest do + use Domain.DataCase, async: true + import Domain.Auth.Adapters.UserPass + alias Domain.Auth + alias Domain.{AccountsFixtures, AuthFixtures} + + describe "identity_changeset/2" do + setup do + account = AccountsFixtures.create_account() + provider = AuthFixtures.create_userpass_provider(account: account) + + %{ + account: account, + provider: provider + } + end + + test "puts password hash in the provider state", %{provider: provider} do + changeset = + %Auth.Identity{} + |> Ecto.Changeset.change( + provider_virtual_state: %{ + password: "Firezone1234", + password_confirmation: "Firezone1234" + } + ) + + assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset) + assert %{provider_state: state, provider_virtual_state: virtual_state} = changeset.changes + + assert %{password_hash: password_hash} = state + assert Domain.Crypto.equal?("Firezone1234", password_hash) + + assert virtual_state == %{} + end + + test "returns error on invalid attrs", %{provider: provider} do + changeset = + %Auth.Identity{} + |> Ecto.Changeset.change( + provider_virtual_state: %{ + password: "short", + password_confirmation: nil + } + ) + + assert changeset = identity_changeset(provider, changeset) + + refute changeset.valid? + + assert errors_on(changeset) == %{ + provider_virtual_state: %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match confirmation"] + } + } + + changeset = + %Auth.Identity{} + |> Ecto.Changeset.change( + provider_virtual_state: %{ + password: "Firezone1234", + password_confirmation: "FirezoneDoesNotMatch" + } + ) + + assert changeset = identity_changeset(provider, changeset) + + refute changeset.valid? + + assert errors_on(changeset) == %{ + provider_virtual_state: %{ + password_confirmation: ["does not match confirmation"] + } + } + end + + test "trims provider identifier", %{provider: provider} do + changeset = + %Auth.Identity{} + |> Ecto.Changeset.change( + provider_identifier: " X ", + provider_virtual_state: %{ + password: "Firezone1234", + password_confirmation: "Firezone1234" + } + ) + + assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset) + assert changeset.changes.provider_identifier == "X" + end + end + + describe "ensure_provisioned/1" do + test "returns changeset as is" do + changeset = %Ecto.Changeset{} + assert ensure_provisioned(changeset) == changeset + end + end + + describe "ensure_deprovisioned/1" do + test "returns changeset as is" do + changeset = %Ecto.Changeset{} + assert ensure_deprovisioned(changeset) == changeset + end + end + + describe "verify_secret/2" do + setup do + account = AccountsFixtures.create_account() + provider = AuthFixtures.create_userpass_provider(account: account) + + identity = + AuthFixtures.create_identity( + account: account, + provider: provider, + provider_virtual_state: %{ + "password" => "Firezone1234", + "password_confirmation" => "Firezone1234" + } + ) + + %{ + account: account, + provider: provider, + identity: identity + } + end + + test "returns :invalid_secret on invalid password", %{identity: identity} do + assert verify_secret(identity, "FirezoneInvalid") == {:error, :invalid_secret} + end + + test "returns :ok on valid password", %{identity: identity} do + assert {:ok, verified_identity, nil} = verify_secret(identity, "Firezone1234") + + assert verified_identity.provider_state["password_hash"] == + identity.provider_state.password_hash + end + end +end diff --git a/apps/domain/test/domain/auth/adapters/userpass_text.exs b/apps/domain/test/domain/auth/adapters/userpass_text.exs deleted file mode 100644 index 63a0041b4..000000000 --- a/apps/domain/test/domain/auth/adapters/userpass_text.exs +++ /dev/null @@ -1,107 +0,0 @@ -# defmodule Domain.Auth.Adapters.UserPassTest do -# use Domain.DataCase, async: true -# import Domain.Auth.Adapters.UserPass - -# test "does not allow to clear the password", %{subject: subject} do -# password = "password1234" -# actor = ActorsFixtures.create_actor(role: :admin, %{password: password}) - -# attrs = %{ -# "password" => nil, -# "password_hash" => nil -# } - -# assert {:ok, updated_actor} = update_actor(actor, attrs, subject) -# assert updated_actor.password_hash == actor.password_hash - -# attrs = %{ -# "password" => "", -# "password_hash" => "" -# } - -# assert {:ok, updated_actor} = update_actor(actor, attrs, subject) -# assert updated_actor.password_hash == actor.password_hash -# end - -# test "returns error on invalid attrs", %{subject: subject, account: account} do -# assert {:error, changeset} = -# create_actor( -# account, -# :unprivileged, -# %{email: "invalid_email", password: "short"}, -# subject -# ) - -# refute changeset.valid? - -# assert errors_on(changeset) == %{ -# email: ["is invalid email address"], -# password: ["should be at least 12 character(s)"], -# password_confirmation: ["can't be blank"] -# } - -# assert {:error, changeset} = -# create_actor( -# account, -# :unprivileged, -# %{email: "invalid_email", password: String.duplicate("A", 65)}, -# subject -# ) - -# refute changeset.valid? -# assert "should be at most 64 character(s)" in errors_on(changeset).password - -# assert {:error, changeset} = -# create_actor(account, :unprivileged, %{email: String.duplicate(" ", 18)}, subject) - -# refute changeset.valid? - -# assert "can't be blank" in errors_on(changeset).email -# end - -# test "requires password confirmation to match the password", %{ -# subject: subject, -# account: account -# } do -# assert {:error, changeset} = -# create_actor( -# account, -# :unprivileged, -# %{password: "foo", password_confirmation: "bar"}, -# subject -# ) - -# assert "does not match confirmation" in errors_on(changeset).password_confirmation - -# assert {:error, changeset} = -# create_actor( -# account, -# :unprivileged, -# %{ -# password: "password1234", -# password_confirmation: "password1234" -# }, -# subject -# ) - -# refute Map.has_key?(errors_on(changeset), :password_confirmation) -# end - -# test "returns error when email is already taken", %{subject: subject, account: account} do -# attrs = ActorsFixtures.actor_attrs() -# assert {:ok, _actor} = create_actor(account, :unprivileged, attrs, subject) -# assert {:error, changeset} = create_actor(account, :unprivileged, attrs, subject) -# refute changeset.valid? -# assert "has already been taken" in errors_on(changeset).email -# end - -# test "trims email", %{subject: subject, account: account} do -# attrs = ActorsFixtures.actor_attrs() -# updated_attrs = Map.put(attrs, :email, " #{attrs.email} ") - -# assert {:ok, actor} = create_actor(account, :unprivileged, updated_attrs, subject) - -# assert actor.email == attrs.email -# end - -# end diff --git a/apps/domain/test/domain/auth_test.exs b/apps/domain/test/domain/auth_test.exs index ce35ff5e8..b8b50b546 100644 --- a/apps/domain/test/domain/auth_test.exs +++ b/apps/domain/test/domain/auth_test.exs @@ -691,6 +691,33 @@ defmodule Domain.AuthTest do assert subject.context.user_agent == user_agent end + test "returned subject expiration depends on user role", %{ + account: account, + provider: provider, + user_agent: user_agent, + remote_ip: remote_ip + } do + actor = ActorsFixtures.create_actor(role: :admin, account: account) + identity = AuthFixtures.create_identity(account: account, provider: provider, actor: actor) + secret = identity.provider_virtual_state.sign_in_token + + assert {:ok, %Auth.Subject{} = subject} = + sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) + + three_hours = 3 * 60 * 60 + assert_datetime_diff(subject.expires_at, DateTime.utc_now(), three_hours) + + actor = ActorsFixtures.create_actor(role: :unprivileged, account: account) + identity = AuthFixtures.create_identity(account: account, provider: provider, actor: actor) + secret = identity.provider_virtual_state.sign_in_token + + assert {:ok, %Auth.Subject{} = subject} = + sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) + + one_week = 7 * 24 * 60 * 60 + assert_datetime_diff(subject.expires_at, DateTime.utc_now(), one_week) + end + test "returns error when provider is disabled", %{ account: account, provider: provider, @@ -791,6 +818,7 @@ defmodule Domain.AuthTest do assert reconstructed_subject.account.id == subject.account.id assert reconstructed_subject.permissions == subject.permissions assert reconstructed_subject.context == subject.context + assert DateTime.diff(reconstructed_subject.expires_at, subject.expires_at) <= 1 end test "updates last signed in fields for identity on success", %{ @@ -847,6 +875,16 @@ defmodule Domain.AuthTest do end end + describe "fetch_session_token_expires_at/2" do + test "returns datetime when the token expires" do + subject = AuthFixtures.create_subject() + {:ok, token} = create_session_token_from_subject(subject) + + assert {:ok, expires_at} = fetch_session_token_expires_at(token) + assert_datetime_diff(expires_at, DateTime.utc_now(), 60) + end + end + describe "has_permission?/2" do setup do account = AccountsFixtures.create_account() diff --git a/apps/domain/test/domain/clients_test.exs b/apps/domain/test/domain/clients_test.exs index 35b2c6fd0..878ab0b51 100644 --- a/apps/domain/test/domain/clients_test.exs +++ b/apps/domain/test/domain/clients_test.exs @@ -437,6 +437,10 @@ defmodule Domain.ClientsTest do assert_raise Ecto.ConstraintError, fn -> NetworkFixtures.create_address(address: client.ipv4, account: account) end + + assert_raise Ecto.ConstraintError, fn -> + NetworkFixtures.create_address(address: client.ipv6, account: account) + end end test "ip addresses are unique per account", %{ diff --git a/apps/domain/test/domain/gateways_test.exs b/apps/domain/test/domain/gateways_test.exs index cc6d452c0..1c631d948 100644 --- a/apps/domain/test/domain/gateways_test.exs +++ b/apps/domain/test/domain/gateways_test.exs @@ -1,7 +1,7 @@ defmodule Domain.GatewaysTest do use Domain.DataCase, async: true import Domain.Gateways - alias Domain.AccountsFixtures + alias Domain.{AccountsFixtures, ResourcesFixtures} alias Domain.{NetworkFixtures, ActorsFixtures, AuthFixtures, GatewaysFixtures} alias Domain.Gateways @@ -440,6 +440,45 @@ defmodule Domain.GatewaysTest do end end + describe "list_connected_gateways_for_resource/1" do + test "returns empty list when there are no online gateways", %{account: account} do + resource = ResourcesFixtures.create_resource(account: account) + + GatewaysFixtures.create_gateway(account: account) + + GatewaysFixtures.create_gateway(account: account) + |> GatewaysFixtures.delete_gateway() + + assert list_connected_gateways_for_resource(resource) == {:ok, []} + end + + test "returns list of connected gateways for a given resource", %{account: account} do + gateway = GatewaysFixtures.create_gateway(account: account) + + resource = + ResourcesFixtures.create_resource( + account: account, + gateways: [%{gateway_id: gateway.id}] + ) + + assert connect_gateway(gateway) == :ok + + assert {:ok, [connected_gateway]} = list_connected_gateways_for_resource(resource) + assert connected_gateway.id == gateway.id + end + + test "does not return connected gateways that are not connected to given resource", %{ + account: account + } do + resource = ResourcesFixtures.create_resource(account: account) + gateway = GatewaysFixtures.create_gateway(account: account) + + assert connect_gateway(gateway) == :ok + + assert list_connected_gateways_for_resource(resource) == {:ok, []} + end + end + describe "change_gateway/1" do test "returns changeset with given changes" do gateway = GatewaysFixtures.create_gateway() diff --git a/apps/domain/test/domain/relays_test.exs b/apps/domain/test/domain/relays_test.exs index 4dd42f162..d475dd07e 100644 --- a/apps/domain/test/domain/relays_test.exs +++ b/apps/domain/test/domain/relays_test.exs @@ -1,7 +1,8 @@ defmodule Domain.RelaysTest do use Domain.DataCase, async: true import Domain.Relays - alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, RelaysFixtures} + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, ResourcesFixtures} + alias Domain.RelaysFixtures alias Domain.Relays setup do @@ -415,6 +416,54 @@ defmodule Domain.RelaysTest do end end + describe "list_connected_relays_for_resource/1" do + test "returns empty list when there are no online relays", %{account: account} do + resource = ResourcesFixtures.create_resource(account: account) + + RelaysFixtures.create_relay(account: account) + + RelaysFixtures.create_relay(account: account) + |> RelaysFixtures.delete_relay() + + assert list_connected_relays_for_resource(resource) == {:ok, []} + end + + test "returns list of connected relays", %{account: account} do + resource = ResourcesFixtures.create_resource(account: account) + relay = RelaysFixtures.create_relay(account: account) + stamp_secret = Ecto.UUID.generate() + + assert connect_relay(relay, stamp_secret) == :ok + + assert {:ok, [connected_relay]} = list_connected_relays_for_resource(resource) + + assert connected_relay.id == relay.id + assert connected_relay.stamp_secret == stamp_secret + end + end + + describe "generate_username_and_password/1" do + test "returns username and password", %{account: account} do + relay = RelaysFixtures.create_relay(account: account) + stamp_secret = Ecto.UUID.generate() + relay = %{relay | stamp_secret: stamp_secret} + expires_at = DateTime.utc_now() |> DateTime.add(3, :second) + + assert %{username: username, password: password, expires_at: expires_at_unix} = + generate_username_and_password(relay, expires_at) + + assert [username_expires_at_unix, username_salt] = String.split(username, ":", parts: 2) + assert username_expires_at_unix == to_string(expires_at_unix) + assert DateTime.from_unix!(expires_at_unix) == DateTime.truncate(expires_at, :second) + + expected_hash = + :crypto.hash(:sha256, "#{expires_at_unix}:#{stamp_secret}:#{username_salt}") + |> Base.encode64(padding: false, case: :lower) + + assert password == expected_hash + end + end + describe "upsert_relay/3" do setup context do token = RelaysFixtures.create_token(account: context.account) @@ -431,7 +480,8 @@ defmodule Domain.RelaysTest do ipv4: "1.1.1.256", ipv6: "fd01::10000", last_seen_user_agent: "foo", - last_seen_remote_ip: {256, 0, 0, 0} + last_seen_remote_ip: {256, 0, 0, 0}, + port: -1 } assert {:error, changeset} = upsert_relay(token, attrs) @@ -439,8 +489,13 @@ defmodule Domain.RelaysTest do assert errors_on(changeset) == %{ ipv4: ["one of these fields must be present: ipv4, ipv6", "is invalid"], ipv6: ["one of these fields must be present: ipv4, ipv6", "is invalid"], - last_seen_user_agent: ["is invalid"] + last_seen_user_agent: ["is invalid"], + port: ["must be greater than or equal to 1"] } + + attrs = %{port: 100_000} + assert {:error, changeset} = upsert_relay(token, attrs) + assert "must be less than or equal to 65535" in errors_on(changeset).port end test "allows creating relay with just required attributes", %{ @@ -462,6 +517,7 @@ defmodule Domain.RelaysTest do assert relay.last_seen_user_agent == attrs.last_seen_user_agent assert relay.last_seen_version == "0.7.412" assert relay.last_seen_at + assert relay.port == 3478 assert Repo.aggregate(Domain.Network.Address, :count) == 0 end @@ -508,6 +564,7 @@ defmodule Domain.RelaysTest do assert updated_relay.ipv4 == relay.ipv4 assert updated_relay.ipv6.address == attrs.ipv6 assert updated_relay.ipv6 != relay.ipv6 + assert updated_relay.port == 3478 assert Repo.aggregate(Domain.Network.Address, :count) == 0 end diff --git a/apps/domain/test/domain/resources_test.exs b/apps/domain/test/domain/resources_test.exs new file mode 100644 index 000000000..b0ea1045a --- /dev/null +++ b/apps/domain/test/domain/resources_test.exs @@ -0,0 +1,351 @@ +defmodule Domain.ResourcesTest do + use Domain.DataCase, async: true + import Domain.Resources + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, GatewaysFixtures, NetworkFixtures} + alias Domain.ResourcesFixtures + alias Domain.Resources + + setup do + account = AccountsFixtures.create_account() + actor = ActorsFixtures.create_actor(role: :admin, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor) + subject = AuthFixtures.create_subject(identity) + + %{ + account: account, + actor: actor, + identity: identity, + subject: subject + } + end + + describe "fetch_resource_by_id/2" do + test "returns error when resource does not exist", %{subject: subject} do + assert fetch_resource_by_id(Ecto.UUID.generate(), subject) == {:error, :not_found} + end + + test "returns error when UUID is invalid", %{subject: subject} do + assert fetch_resource_by_id("foo", subject) == {:error, :not_found} + end + + test "returns resource when resource exists", %{account: account, subject: subject} do + gateway = GatewaysFixtures.create_gateway(account: account) + resource = ResourcesFixtures.create_resource(account: account, gateway: gateway) + + assert {:ok, fetched_resource} = fetch_resource_by_id(resource.id, subject) + assert fetched_resource.id == resource.id + end + + test "does not return deleted resources", %{account: account, subject: subject} do + gateway = GatewaysFixtures.create_gateway(account: account) + + {:ok, resource} = + ResourcesFixtures.create_resource(account: account, gateway: gateway) + |> delete_resource(subject) + + assert fetch_resource_by_id(resource.id, subject) == {:error, :not_found} + end + + test "does not return resources in other accounts", %{subject: subject} do + resource = ResourcesFixtures.create_resource() + assert fetch_resource_by_id(resource.id, subject) == {:error, :not_found} + end + + test "returns error when subject has no permission to view resources", %{subject: subject} do + subject = AuthFixtures.remove_permissions(subject) + + assert fetch_resource_by_id(Ecto.UUID.generate(), subject) == + {:error, + {:unauthorized, + [missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}} + end + end + + describe "list_resources/1" do + test "returns empty list when there are no resources", %{subject: subject} do + assert list_resources(subject) == {:ok, []} + end + + test "does not list resources from other accounts", %{ + subject: subject + } do + ResourcesFixtures.create_resource() + assert list_resources(subject) == {:ok, []} + end + + test "does not list deleted resources", %{ + account: account, + subject: subject + } do + ResourcesFixtures.create_resource(account: account) + |> delete_resource(subject) + + assert list_resources(subject) == {:ok, []} + end + + test "returns all resources", %{ + account: account, + subject: subject + } do + ResourcesFixtures.create_resource(account: account) + ResourcesFixtures.create_resource(account: account) + ResourcesFixtures.create_resource() + + assert {:ok, resources} = list_resources(subject) + assert length(resources) == 2 + end + + test "returns error when subject has no permission to manage resources", %{ + subject: subject + } do + subject = AuthFixtures.remove_permissions(subject) + + assert list_resources(subject) == + {:error, + {:unauthorized, + [missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}} + end + end + + describe "create_resource/2" do + test "returns changeset error on empty attrs", %{subject: subject} do + assert {:error, changeset} = create_resource(%{}, subject) + + assert errors_on(changeset) == %{ + address: ["can't be blank"], + connections: ["can't be blank"] + } + end + + test "returns error on invalid attrs", %{subject: subject} do + attrs = %{"name" => String.duplicate("a", 256), "filters" => :foo, "connections" => :bar} + assert {:error, changeset} = create_resource(attrs, subject) + + assert errors_on(changeset) == %{ + address: ["can't be blank"], + name: ["should be at most 255 character(s)"], + filters: ["is invalid"], + connections: ["is invalid"] + } + end + + test "returns error on duplicate name", %{account: account, subject: subject} do + gateway = GatewaysFixtures.create_gateway(account: account) + resource = ResourcesFixtures.create_resource(account: account, subject: subject) + address = ResourcesFixtures.resource_attrs().address + + attrs = %{ + "name" => resource.name, + "address" => address, + "connections" => [%{"gateway_id" => gateway.id}] + } + + assert {:error, changeset} = create_resource(attrs, subject) + assert errors_on(changeset) == %{name: ["has already been taken"]} + end + + test "creates a resource", %{account: account, subject: subject} do + gateway = GatewaysFixtures.create_gateway(account: account) + attrs = ResourcesFixtures.resource_attrs(connections: [%{gateway_id: gateway.id}]) + assert {:ok, resource} = create_resource(attrs, subject) + + assert resource.address == attrs.address + assert resource.name == attrs.address + assert resource.account_id == account.id + + refute is_nil(resource.ipv4) + refute is_nil(resource.ipv6) + + assert [ + %Domain.Resources.Connection{ + resource_id: resource_id, + gateway_id: gateway_id, + account_id: account_id + } + ] = resource.connections + + assert resource_id == resource.id + assert gateway_id == gateway.id + assert account_id == account.id + + assert [ + %Domain.Resources.Resource.Filter{ports: ["80", "433"], protocol: :tcp}, + %Domain.Resources.Resource.Filter{ports: ["100 - 200"], protocol: :udp} + ] = resource.filters + end + + test "does not allow to reuse IP addresses within an account", %{ + account: account, + subject: subject + } do + gateway = GatewaysFixtures.create_gateway(account: account) + attrs = ResourcesFixtures.resource_attrs(connections: [%{gateway_id: gateway.id}]) + assert {:ok, resource} = create_resource(attrs, subject) + + addresses = + Domain.Network.Address + |> Repo.all() + |> Enum.map(fn %Domain.Network.Address{address: address, type: type} -> + %{address: address, type: type} + end) + + assert %{address: resource.ipv4, type: :ipv4} in addresses + assert %{address: resource.ipv6, type: :ipv6} in addresses + + assert_raise Ecto.ConstraintError, fn -> + NetworkFixtures.create_address(address: resource.ipv4, account: account) + end + + assert_raise Ecto.ConstraintError, fn -> + NetworkFixtures.create_address(address: resource.ipv6, account: account) + end + end + + test "ip addresses are unique per account", %{ + account: account, + subject: subject + } do + gateway = GatewaysFixtures.create_gateway(account: account) + attrs = ResourcesFixtures.resource_attrs(connections: [%{gateway_id: gateway.id}]) + assert {:ok, resource} = create_resource(attrs, subject) + + assert %Domain.Network.Address{} = NetworkFixtures.create_address(address: resource.ipv4) + assert %Domain.Network.Address{} = NetworkFixtures.create_address(address: resource.ipv6) + end + + test "returns error when subject has no permission to create resources", %{ + subject: subject + } do + subject = AuthFixtures.remove_permissions(subject) + + assert create_resource(%{}, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}} + end + end + + describe "update_resource/3" do + setup context do + resource = + ResourcesFixtures.create_resource( + account: context.account, + subject: context.subject + ) + + Map.put(context, :resource, resource) + end + + test "does nothing on empty attrs", %{resource: resource, subject: subject} do + assert {:ok, _resource} = update_resource(resource, %{}, subject) + end + + test "returns error on invalid attrs", %{resource: resource, subject: subject} do + attrs = %{"name" => String.duplicate("a", 256), "filters" => :foo, "connections" => :bar} + assert {:error, changeset} = update_resource(resource, attrs, subject) + + assert errors_on(changeset) == %{ + name: ["should be at most 255 character(s)"], + filters: ["is invalid"], + connections: ["is invalid"] + } + end + + test "allows to update name", %{resource: resource, subject: subject} do + attrs = %{"name" => "foo"} + assert {:ok, resource} = update_resource(resource, attrs, subject) + assert resource.name == "foo" + end + + test "allows to update filters", %{resource: resource, subject: subject} do + attrs = %{"filters" => []} + assert {:ok, resource} = update_resource(resource, attrs, subject) + assert resource.filters == [] + end + + test "allows to update connections", %{account: account, resource: resource, subject: subject} do + gateway1 = GatewaysFixtures.create_gateway(account: account) + + attrs = %{"connections" => [%{gateway_id: gateway1.id}]} + assert {:ok, resource} = update_resource(resource, attrs, subject) + gateway_ids = Enum.map(resource.connections, & &1.gateway_id) + assert gateway_ids == [gateway1.id] + + gateway2 = GatewaysFixtures.create_gateway(account: account) + attrs = %{"connections" => [%{gateway_id: gateway1.id}, %{gateway_id: gateway2.id}]} + assert {:ok, resource} = update_resource(resource, attrs, subject) + gateway_ids = Enum.map(resource.connections, & &1.gateway_id) + assert Enum.sort(gateway_ids) == Enum.sort([gateway1.id, gateway2.id]) + + attrs = %{"connections" => [%{gateway_id: gateway2.id}]} + assert {:ok, resource} = update_resource(resource, attrs, subject) + gateway_ids = Enum.map(resource.connections, & &1.gateway_id) + assert gateway_ids == [gateway2.id] + end + + test "does not allow to remove all connections", %{resource: resource, subject: subject} do + attrs = %{"connections" => []} + assert {:error, changeset} = update_resource(resource, attrs, subject) + + assert errors_on(changeset) == %{ + connections: ["can't be blank"] + } + end + + test "does not allow to update address", %{resource: resource, subject: subject} do + attrs = %{"address" => "foo"} + assert {:ok, updated_resource} = update_resource(resource, attrs, subject) + assert updated_resource.address == resource.address + end + + test "returns error when subject has no permission to create resources", %{ + resource: resource, + subject: subject + } do + subject = AuthFixtures.remove_permissions(subject) + + assert update_resource(resource, %{}, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}} + end + end + + describe "delete_resource/2" do + setup context do + resource = + ResourcesFixtures.create_resource( + account: context.account, + subject: context.subject + ) + + Map.put(context, :resource, resource) + end + + test "returns error on state conflict", %{ + resource: resource, + subject: subject + } do + assert {:ok, deleted} = delete_resource(resource, subject) + assert delete_resource(deleted, subject) == {:error, :not_found} + assert delete_resource(resource, subject) == {:error, :not_found} + end + + test "deletes gateways", %{resource: resource, subject: subject} do + assert {:ok, deleted} = delete_resource(resource, subject) + assert deleted.deleted_at + end + + test "returns error when subject has no permission to delete resources", %{ + resource: resource, + subject: subject + } do + subject = AuthFixtures.remove_permissions(subject) + + assert delete_resource(resource, subject) == + {:error, + {:unauthorized, + [missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}} + end + end +end diff --git a/apps/domain/test/support/data_case.ex b/apps/domain/test/support/data_case.ex index d7b161c57..b467c17d6 100644 --- a/apps/domain/test/support/data_case.ex +++ b/apps/domain/test/support/data_case.ex @@ -21,10 +21,15 @@ defmodule Domain.DataCase do import Ecto import Ecto.Changeset import Domain.DataCase + alias Domain.Repo end end + def assert_datetime_diff(%DateTime{} = datetime1, %DateTime{} = datetime2, is, leeway \\ 5) do + assert DateTime.diff(datetime1, datetime2, :second) in (is - leeway)..(is + leeway) + end + @doc """ A helper that transforms changeset errors into a map of messages. diff --git a/apps/domain/test/support/fixtures/auth_fixtures.ex b/apps/domain/test/support/fixtures/auth_fixtures.ex index 7f84e6fbb..c702da836 100644 --- a/apps/domain/test/support/fixtures/auth_fixtures.ex +++ b/apps/domain/test/support/fixtures/auth_fixtures.ex @@ -15,6 +15,10 @@ defmodule Domain.AuthFixtures do Ecto.UUID.generate() end + def random_provider_identifier(%Domain.Auth.Provider{adapter: :userpass, name: name}) do + "user-#{counter()}@#{String.downcase(name)}.com" + end + def provider_attrs(attrs \\ %{}) do Enum.into(attrs, %{ name: "provider-#{counter()}", @@ -54,6 +58,20 @@ defmodule Domain.AuthFixtures do {provider, bypass} end + def create_userpass_provider(attrs \\ %{}) do + attrs = Enum.into(attrs, %{}) + + {account, _attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + + attrs = provider_attrs(adapter: :userpass) + + {:ok, provider} = Auth.create_provider(account, attrs) + provider + end + def create_identity(attrs \\ %{}) do attrs = Enum.into(attrs, %{}) @@ -81,8 +99,21 @@ defmodule Domain.AuthFixtures do ) end) - {:ok, identity} = Auth.create_identity(actor, provider, provider_identifier) - identity + {provider_virtual_state, attrs} = + Map.pop_lazy(attrs, :provider_virtual_state, fn -> + %{} + end) + + {:ok, identity} = + Auth.create_identity(actor, provider, provider_identifier, provider_virtual_state) + + if state = Map.get(attrs, :provider_state) do + identity + |> Ecto.Changeset.change(provider_state: state) + |> Repo.update!() + else + identity + end end def create_subject do @@ -100,6 +131,7 @@ defmodule Domain.AuthFixtures do actor: identity.actor, permissions: Auth.Roles.build(identity.actor.role).permissions, account: identity.account, + expires_at: DateTime.utc_now() |> DateTime.add(60, :second), context: %Auth.Context{remote_ip: remote_ip(), user_agent: user_agent()} } end diff --git a/apps/domain/test/support/fixtures/resources_fixtures.ex b/apps/domain/test/support/fixtures/resources_fixtures.ex new file mode 100644 index 000000000..2e4e6a255 --- /dev/null +++ b/apps/domain/test/support/fixtures/resources_fixtures.ex @@ -0,0 +1,52 @@ +# TODO: Domain.Fixtures.Resources +defmodule Domain.ResourcesFixtures do + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, GatewaysFixtures} + + def resource_attrs(attrs \\ %{}) do + address = "admin-#{counter()}.mycorp.com" + + Enum.into(attrs, %{ + address: address, + name: address, + filters: [ + %{protocol: :tcp, ports: [80, 433]}, + %{protocol: :udp, ports: [100..200]} + ] + }) + end + + def create_resource(attrs \\ %{}) do + attrs = resource_attrs(attrs) + + {account, attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + + {connections, attrs} = + Map.pop_lazy(attrs, :gateways, fn -> + Enum.map(1..2, fn _ -> + gateway = GatewaysFixtures.create_gateway(account: account) + %{gateway_id: gateway.id} + end) + end) + + {subject, attrs} = + Map.pop_lazy(attrs, :subject, fn -> + actor = ActorsFixtures.create_actor(role: :admin, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor) + AuthFixtures.create_subject(identity) + end) + + {:ok, resource} = + attrs + |> Map.put(:connections, connections) + |> Domain.Resources.create_resource(subject) + + resource + end + + defp counter do + System.unique_integer([:positive]) + end +end diff --git a/apps/web/test/support/mailer_test_adapter.ex b/apps/web/test/support/mailer_test_adapter.ex new file mode 100644 index 000000000..8ca8237fa --- /dev/null +++ b/apps/web/test/support/mailer_test_adapter.ex @@ -0,0 +1,15 @@ +defmodule Web.MailerTestAdapter do + use Swoosh.Adapter + + @impl true + def deliver(email, config) do + Swoosh.Adapters.Local.deliver(email, config) + Swoosh.Adapters.Test.deliver(email, config) + end + + @impl true + def deliver_many(emails, config) do + Swoosh.Adapters.Local.deliver_many(emails, config) + Swoosh.Adapters.Test.deliver_many(emails, config) + end +end diff --git a/config/config.exs b/config/config.exs index c875388ae..f0e37a212 100644 --- a/config/config.exs +++ b/config/config.exs @@ -65,8 +65,7 @@ config :domain, config :domain, Domain.Auth, key_base: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5SD", - salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDejX", - max_age: 30 * 60 + salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDejX" ############################### ##### Web #####################