Skip to content

Commit c986666

Browse files
committed
building upon @jjcarstens psk magic
this is based upon 0d44fc2 with the addition of Product Auth Tokens (needs some naming love)
1 parent eac1083 commit c986666

File tree

10 files changed

+237
-7
lines changed

10 files changed

+237
-7
lines changed

lib/nerves_hub/devices.ex

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ defmodule NervesHub.Devices do
1919
alias NervesHub.Firmwares
2020
alias NervesHub.Firmwares.Firmware
2121
alias NervesHub.Firmwares.FirmwareMetadata
22+
alias NervesHub.Products
2223
alias NervesHub.Products.Product
2324
alias NervesHub.Repo
2425
alias NervesHub.TaskSupervisor, as: Tasks
@@ -27,8 +28,24 @@ defmodule NervesHub.Devices do
2728

2829
@min_fwup_delta_updatable_version ">=1.6.0"
2930

30-
def get_device(device_id), do: Repo.get(Device, device_id)
31-
def get_device!(device_id), do: Repo.get!(Device, device_id)
31+
def get_device!(device_id) do
32+
Repo.get!(Device, device_id)
33+
end
34+
35+
def get_device(device_id) when is_integer(device_id) do
36+
Repo.get(Device, device_id)
37+
end
38+
39+
def get_device(product_id: product_id, identifier: identifier) do
40+
Device
41+
|> where([d], d.product_id == ^product_id)
42+
|> where([d], d.identifier == ^identifier)
43+
|> Repo.one()
44+
|> case do
45+
nil -> {:error, :not_found}
46+
device -> {:ok, device}
47+
end
48+
end
3249

3350
def get_devices_by_org_id(org_id) do
3451
query =
@@ -223,6 +240,22 @@ defmodule NervesHub.Devices do
223240
end
224241
end
225242

243+
def get_or_create_device(token_auth: token_auth, identifier: identifier) do
244+
with {:error, :not_found} <-
245+
get_device(product_id: token_auth.product_id, identifier: identifier),
246+
{:ok, product} <-
247+
Products.get_product(token_auth.product_id) do
248+
create_device(%{
249+
org_id: product.org_id,
250+
product_id: product.id,
251+
identifier: identifier
252+
})
253+
else
254+
result ->
255+
result
256+
end
257+
end
258+
226259
def get_device_by(filters) do
227260
Repo.get_by(Device, filters)
228261
|> case do

lib/nerves_hub/products.ex

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ defmodule NervesHub.Products do
77

88
alias Ecto.Multi
99
alias NervesHub.{Certificate, Repo}
10-
alias NervesHub.Products.Product
10+
alias NervesHub.Products.{Product, TokenAuth}
1111
alias NervesHub.Accounts.{User, Org, OrgUser}
1212

1313
alias NimbleCSV.RFC4180, as: CSV
@@ -54,6 +54,16 @@ defmodule NervesHub.Products do
5454
|> Repo.get!(id)
5555
end
5656

57+
def get_product(id) do
58+
Product
59+
|> Repo.exclude_deleted()
60+
|> Repo.get(id)
61+
|> case do
62+
nil -> {:error, :not_found}
63+
product -> {:ok, product}
64+
end
65+
end
66+
5767
def get_product_by_org_id_and_name(org_id, name) do
5868
Product
5969
|> Repo.exclude_deleted()
@@ -130,6 +140,15 @@ defmodule NervesHub.Products do
130140
Product.changeset(product, %{})
131141
end
132142

143+
def get_token_auth(access_id: access_id) do
144+
TokenAuth
145+
|> join(:inner, [ta], p in assoc(ta, :product))
146+
|> where([ta], ta.access_id == ^access_id)
147+
|> where([ta], is_nil(ta.deactivated_at))
148+
|> where([p], is_nil(p.deleted_at))
149+
|> Repo.one()
150+
end
151+
133152
def devices_csv(%Product{} = product) do
134153
product = Repo.preload(product, [:org, devices: :device_certificates])
135154
data = Enum.map(product.devices, &device_csv_line(&1, product))

lib/nerves_hub/products/product.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ defmodule NervesHub.Products.Product do
77
alias NervesHub.Devices.CACertificate
88
alias NervesHub.Devices.Device
99
alias NervesHub.Firmwares.Firmware
10+
alias NervesHub.Products.TokenAuth
1011
alias NervesHub.Repo
1112

1213
@required_params [:name, :org_id]
@@ -19,6 +20,7 @@ defmodule NervesHub.Products.Product do
1920
has_many(:firmwares, Firmware)
2021
has_many(:jitp, CACertificate.JITP)
2122
has_many(:archives, Archive)
23+
has_many(:auth_tokens, TokenAuth)
2224

2325
belongs_to(:org, Org, where: [deleted_at: nil])
2426

lib/nerves_hub/products/token_auth.ex

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
defmodule NervesHub.Products.TokenAuth do
2+
use Ecto.Schema
3+
4+
import Ecto.Changeset
5+
6+
alias NervesHub.Products.Product
7+
8+
@type t :: %__MODULE__{}
9+
10+
@access_id_prefix "nhp"
11+
12+
schema "product_auth_tokens" do
13+
belongs_to(:product, Product)
14+
15+
field(:access_id, :string)
16+
field(:secret, :string)
17+
18+
field(:deactivated_at, :utc_datetime)
19+
20+
timestamps()
21+
end
22+
23+
def create_changeset(%Product{} = product) do
24+
change(%__MODULE__{}, product_id: product.id)
25+
|> put_change(:access_id, "#{@access_id_prefix}_#{generate_token()}")
26+
|> put_change(:secret, generate_token())
27+
|> validate_required([:product_id, :access_id, :secret])
28+
|> validate_format(:access_id, ~r/^nhp_[a-zA-Z0-9\-\/\+]{43}$/)
29+
|> validate_format(:secret, ~r/^[a-zA-Z0-9\-\/\+]{43}$/)
30+
|> foreign_key_constraint(:product_id)
31+
|> unique_constraint(:access_id)
32+
|> unique_constraint(:secret)
33+
end
34+
35+
defp generate_token() do
36+
:crypto.strong_rand_bytes(32) |> Base.encode64(padding: false)
37+
end
38+
end

lib/nerves_hub_web/channels/device_socket.ex renamed to lib/nerves_hub_web/channels/device_socket_cert_auth.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
defmodule NervesHubWeb.DeviceSocket do
1+
defmodule NervesHubWeb.DeviceSocketCertAuth do
22
use Phoenix.Socket
33

44
alias NervesHub.Devices
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
defmodule NervesHubWeb.DeviceSocketTokenAuth do
2+
use Phoenix.Socket
3+
4+
alias NervesHub.Devices
5+
6+
alias Plug.Crypto
7+
8+
channel("console", NervesHubWeb.ConsoleChannel)
9+
channel("device", NervesHubWeb.DeviceChannel)
10+
11+
@salt_headers [
12+
"x-nh-key-digest",
13+
"x-nh-key-iterations",
14+
"x-nh-key-length",
15+
"x-nh-access-id",
16+
"x-nh-time"
17+
]
18+
19+
# Default 15 min max age for the signature
20+
@default_max_age 900
21+
22+
def connect(_params, socket, %{x_headers: headers}) do
23+
parsed_data = parse_headers(headers)
24+
verify_opts = verify_options(parsed_data)
25+
salt = expected_salt(headers)
26+
27+
with {:ok, access_id} <- Keyword.fetch(parsed_data, :access_id),
28+
{:ok, token_auth} <- get_product_token_auth(access_id),
29+
{:ok, signature} <- Keyword.fetch(parsed_data, :signature),
30+
{:ok, identifier} <- Crypto.verify(token_auth.secret, salt, signature, verify_opts),
31+
{:ok, device} <-
32+
Devices.get_or_create_device(token_auth: token_auth, identifier: identifier) do
33+
socket =
34+
socket
35+
|> assign(:device, device)
36+
|> assign(:reference_id, generate_reference_id())
37+
38+
{:ok, socket}
39+
else
40+
_ -> {:error, :invalud_auth}
41+
end
42+
end
43+
44+
def connect(_params, _socket, _connect_info), do: :error
45+
46+
def id(%{assigns: %{device: device}}), do: "device_socket:#{device.id}"
47+
def id(_socket), do: nil
48+
49+
defp parse_headers(headers) do
50+
for {k, v} <- headers do
51+
case String.downcase(k) do
52+
"x-nh-time" ->
53+
{:signed_at, String.to_integer(v)}
54+
55+
"x-nh-key-length" ->
56+
{:key_length, String.to_integer(v)}
57+
58+
"x-nh-key-iterations" ->
59+
{:key_iterations, String.to_integer(v)}
60+
61+
"x-nh-key-digest" ->
62+
"NH1-HMAC-" <> digest_str = v
63+
{:key_digest, String.to_existing_atom(String.downcase(digest_str))}
64+
65+
"x-nh-access-id" ->
66+
{:access_id, v}
67+
68+
"x-nh-signature" ->
69+
{:signature, v}
70+
71+
_ ->
72+
# TODO: What should we do with this unknown?
73+
nil
74+
end
75+
end
76+
end
77+
78+
defp verify_options(parsed_data) do
79+
Keyword.take(parsed_data, [:key_digest, :key_iterations, :key_length, :signed_at])
80+
# TODO: Make max_age configurable?
81+
|> Keyword.put(:max_age, @default_max_age)
82+
end
83+
84+
defp expected_salt(headers) do
85+
salt_headers =
86+
Enum.filter(headers, &(elem(&1, 0) in @salt_headers))
87+
|> Enum.sort_by(fn {k, _} -> k end)
88+
|> Enum.map_join("\n", fn {k, v} -> "#{k}=#{v}" end)
89+
90+
"""
91+
NH1:device:token:connect
92+
93+
#{salt_headers}
94+
"""
95+
end
96+
97+
defp get_product_token_auth(access_id) do
98+
case NervesHub.Products.get_token_auth(access_id: access_id) do
99+
nil -> {:error, :unknown_access_id}
100+
token_auth -> {:ok, token_auth}
101+
end
102+
end
103+
104+
defp generate_reference_id() do
105+
Base.encode32(:crypto.strong_rand_bytes(2), padding: false)
106+
end
107+
108+
def handle_error(conn, :invalud_auth),
109+
do: Plug.Conn.send_resp(conn, 403, "Invalid authorization")
110+
end

lib/nerves_hub_web/device_endpoint.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule NervesHubWeb.DeviceEndpoint do
44

55
socket(
66
"/socket",
7-
NervesHubWeb.DeviceSocket,
7+
NervesHubWeb.DeviceSocketCertAuth,
88
websocket: [
99
connect_info: [:peer_data, :x_headers]
1010
]

lib/nerves_hub_web/endpoint.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ defmodule NervesHubWeb.Endpoint do
1414
websocket: [connect_info: [session: @session_options]]
1515
)
1616

17+
socket("/device-socket", NervesHubWeb.DeviceSocketTokenAuth,
18+
websocket: [
19+
connect_info: [:x_headers],
20+
error_handler: {NervesHubWeb.DeviceSocketTokenAuth, :handle_error, []}
21+
]
22+
)
23+
1724
# Serve at "/" the static files from "priv/static" directory.
1825
#
1926
# You should set gzip to true if you are running phoenix.digest
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
defmodule NervesHub.Repo.Migrations.CreateProductAuthTokens do
2+
use Ecto.Migration
3+
4+
def change do
5+
create table(:product_auth_tokens) do
6+
add(:product_id, references(:products), null: false)
7+
8+
add(:access_id, :string, null: false)
9+
add(:secret, :string, null: false)
10+
11+
add(:deactivated_at, :utc_datetime)
12+
13+
timestamps()
14+
end
15+
16+
create index(:product_auth_tokens, [:access_id], unique: true)
17+
create index(:product_auth_tokens, [:secret], unique: true)
18+
end
19+
end

test/nerves_hub_web/channels/device_channel_test.exs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule NervesHubWeb.DeviceChannelTest do
33
use DefaultMocks
44

55
alias NervesHub.Fixtures
6-
alias NervesHubWeb.DeviceChannel
6+
alias NervesHubWeb.DeviceChannelCertAuth
77
alias NervesHubWeb.DeviceSocket
88

99
test "basic connection to the channel" do
@@ -16,7 +16,9 @@ defmodule NervesHubWeb.DeviceChannelTest do
1616
%{db_cert: certificate, cert: _cert} = Fixtures.device_certificate_fixture(device)
1717

1818
{:ok, socket} =
19-
connect(DeviceSocket, %{}, connect_info: %{peer_data: %{ssl_cert: certificate.der}})
19+
connect(DeviceChannelCertAuth, %{},
20+
connect_info: %{peer_data: %{ssl_cert: certificate.der}}
21+
)
2022

2123
# Joins the channel
2224
assert {:ok, %{}, socket} = subscribe_and_join(socket, DeviceChannel, "device")

0 commit comments

Comments
 (0)