mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
Implementing channels logic (#1619)
This commit is contained in:
13
.github/workflows/test.yml
vendored
13
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
11
apps/api/lib/api/client/views/interface.ex
Normal file
11
apps/api/lib/api/client/views/interface.ex
Normal file
@@ -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
|
||||
39
apps/api/lib/api/client/views/relay.ex
Normal file
39
apps/api/lib/api/client/views/relay.ex
Normal file
@@ -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
|
||||
16
apps/api/lib/api/client/views/resource.ex
Normal file
16
apps/api/lib/api/client/views/resource.ex
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
9
apps/api/lib/api/gateway/views/actor.ex
Normal file
9
apps/api/lib/api/gateway/views/actor.ex
Normal file
@@ -0,0 +1,9 @@
|
||||
defmodule API.Gateway.Views.Actor do
|
||||
alias Domain.Actors
|
||||
|
||||
def render(%Actors.Actor{} = actor) do
|
||||
%{
|
||||
id: actor.id
|
||||
}
|
||||
end
|
||||
end
|
||||
17
apps/api/lib/api/gateway/views/client.ex
Normal file
17
apps/api/lib/api/gateway/views/client.ex
Normal file
@@ -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
|
||||
10
apps/api/lib/api/gateway/views/interface.ex
Normal file
10
apps/api/lib/api/gateway/views/interface.ex
Normal file
@@ -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
|
||||
5
apps/api/lib/api/gateway/views/relay.ex
Normal file
5
apps/api/lib/api/gateway/views/relay.ex
Normal file
@@ -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
|
||||
12
apps/api/lib/api/gateway/views/resource.ex
Normal file
12
apps/api/lib/api/gateway/views/resource.ex
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
10
apps/domain/lib/domain/auth/adapters/userpass/password.ex
Normal file
10
apps/domain/lib/domain/auth/adapters/userpass/password.ex
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
defmodule Domain.Auth.Identities do
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
defmodule Domain.Auth.Providers do
|
||||
end
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
117
apps/domain/lib/domain/resources.ex
Normal file
117
apps/domain/lib/domain/resources.ex
Normal file
@@ -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
|
||||
25
apps/domain/lib/domain/resources/authorizer.ex
Normal file
25
apps/domain/lib/domain/resources/authorizer.ex
Normal file
@@ -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
|
||||
11
apps/domain/lib/domain/resources/connection.ex
Normal file
11
apps/domain/lib/domain/resources/connection.ex
Normal file
@@ -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
|
||||
16
apps/domain/lib/domain/resources/connection/changeset.ex
Normal file
16
apps/domain/lib/domain/resources/connection/changeset.ex
Normal file
@@ -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
|
||||
11
apps/domain/lib/domain/resources/connection/query.ex
Normal file
11
apps/domain/lib/domain/resources/connection/query.ex
Normal file
@@ -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
|
||||
23
apps/domain/lib/domain/resources/resource.ex
Normal file
23
apps/domain/lib/domain/resources/resource.ex
Normal file
@@ -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
|
||||
63
apps/domain/lib/domain/resources/resource/changeset.ex
Normal file
63
apps/domain/lib/domain/resources/resource/changeset.ex
Normal file
@@ -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
|
||||
16
apps/domain/lib/domain/resources/resource/query.ex
Normal file
16
apps/domain/lib/domain/resources/resource/query.ex
Normal file
@@ -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
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -34,7 +34,8 @@ defmodule Domain.MixProject do
|
||||
mod: {Domain.Application, []},
|
||||
extra_applications: [
|
||||
:logger,
|
||||
:runtime_tools
|
||||
:runtime_tools,
|
||||
:crypto
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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("")
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
141
apps/domain/test/domain/auth/adapters/userpass_test.exs
Normal file
141
apps/domain/test/domain/auth/adapters/userpass_test.exs
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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", %{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
351
apps/domain/test/domain/resources_test.exs
Normal file
351
apps/domain/test/domain/resources_test.exs
Normal file
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
52
apps/domain/test/support/fixtures/resources_fixtures.ex
Normal file
52
apps/domain/test/support/fixtures/resources_fixtures.ex
Normal file
@@ -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
|
||||
15
apps/web/test/support/mailer_test_adapter.ex
Normal file
15
apps/web/test/support/mailer_test_adapter.ex
Normal file
@@ -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
|
||||
@@ -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 #####################
|
||||
|
||||
Reference in New Issue
Block a user