Browse Source

feat(device): Add logic to generate and store pairable devices.

jackyalcine 2 months ago
parent
commit
9bb4be780f
Signed by: Jacky Alciné <yo@jacky.wtf> GPG Key ID: 537A4F904B15268D

+ 1
- 0
lib/application.ex View File

@@ -9,6 +9,7 @@ defmodule Fortress.Application do
9 9
     children = [
10 10
       worker(Guardian.DB.Token.SweeperServer, []),
11 11
       worker(Cachex, [:fortress, []]),
12
+      worker(Fortress.Workers.Chronic, []),
12 13
       {Task.Supervisor, name: Fortress.Workers.Provider},
13 14
       supervisor(Fortress.Repo, []),
14 15
       supervisor(Fortress.Web.Endpoint, []),

+ 32
- 0
lib/device.ex View File

@@ -0,0 +1,32 @@
1
+defmodule Fortress.Device do
2
+  require Ecto.Query
3
+
4
+  @otp_secret_size 64
5
+
6
+  def park(key) do
7
+    case Fortress.Repo.Device.upsert(key) do
8
+      {:ok, device} -> {:ok, code: device.pairing_code}
9
+      {:error, _} = error -> error
10
+    end
11
+  end
12
+
13
+  def pair(code, site) do
14
+    with(
15
+      device when not is_nil(device) <- Fortress.Repo.Device.find_by_code(code),
16
+      secret <- @otp_secret_size |> :crypto.strong_rand_bytes() |> Base.encode32(),
17
+      {:ok, refreshed_device} <-
18
+        device
19
+        |> Fortress.Repo.Device.changeset(%{site: site, otp_secret: secret, pairing_code: ""})
20
+        |> Fortress.Repo.update()
21
+    ) do
22
+      {:ok, device: refreshed_device}
23
+    else
24
+      error -> error
25
+    end
26
+  end
27
+
28
+  def sweep() do
29
+    Fortress.Repo.Device.unclaimed()
30
+    |> Fortress.Repo.delete_all()
31
+  end
32
+end

+ 1
- 0
lib/guardian.ex View File

@@ -6,6 +6,7 @@ defmodule Fortress.Guardian do
6 6
   end
7 7
 
8 8
   def resource_from_claims(claims) do
9
+    Apex.ap claims
9 10
     url = claims["sub"]
10 11
     {:ok, Fortress.Repo.Site.find_by_url(url)}
11 12
   end

+ 57
- 0
lib/repo/device.ex View File

@@ -0,0 +1,57 @@
1
+defmodule Fortress.Repo.Device do
2
+  use Ecto.Schema
3
+  import Ecto.Query, only: [from: 2]
4
+  import Ecto.Changeset
5
+
6
+  @primary_key {:id, :binary_id, autogenerate: true}
7
+  @foreign_key_type :binary_id
8
+  @required_keys ~w(key)a
9
+  @optional_keys ~w(last_used_at site name pairing_code otp_secret)a
10
+  schema "devices" do
11
+    field :key, :string
12
+    field :last_used_at, :utc_datetime
13
+    field :name, :string
14
+    field :otp_secret, :string
15
+      field :pairing_code, :string
16
+    belongs_to :owning_site, Fortress.Repo.Site, foreign_key: :site, type: :string
17
+
18
+    timestamps()
19
+  end
20
+
21
+  @doc false
22
+  def changeset(device, attrs) do
23
+    device
24
+    |> cast(attrs, @required_keys ++ @optional_keys)
25
+    |> validate_required(@required_keys)
26
+    |> unique_constraint(:key)
27
+  end
28
+
29
+  def create(key) do
30
+    encoded_key = key |> Base.encode64(padding: false)
31
+    pairing_code = 4 |> :crypto.strong_rand_bytes() |> Base.encode32(case: :upper, padding: false)
32
+    %__MODULE__{}
33
+    |> changeset(%{key: encoded_key, pairing_code: pairing_code})
34
+    |> Fortress.Repo.insert
35
+  end
36
+
37
+  def find_by_key(key) do
38
+    encoded_key = key |> Base.encode64(padding: false)
39
+    Fortress.Repo.get_by(__MODULE__, key: encoded_key)
40
+  end
41
+
42
+  def find_by_code(code) do
43
+    Fortress.Repo.get_by(__MODULE__, pairing_code: code)
44
+  end
45
+
46
+  def upsert(key) do
47
+    case find_by_key(key) do
48
+      nil -> create(key)
49
+      device -> {:ok, device}
50
+    end
51
+  end
52
+
53
+  def unclaimed() do
54
+    age = DateTime.utc_now |> DateTime.add(-600, :second)
55
+    from device in __MODULE__, where: device.inserted_at < ^age and device.site == ""
56
+  end
57
+end

+ 6
- 0
lib/repo/site_provider.ex View File

@@ -75,6 +75,12 @@ defmodule Fortress.Repo.SiteProvider do
75 75
     }
76 76
   end
77 77
 
78
+  def clear_providers_for(site, provider) do
79
+    __MODULE__
80
+    |> Ecto.Query.where(provider: ^provider)
81
+    |> Fortress.Repo.delete_all
82
+  end
83
+
78 84
   def bump_last_use(site, provider) do
79 85
     case Fortress.Repo.get_by(__MODULE__, site: site, provider: provider) do
80 86
       %__MODULE__{} = record ->

+ 46
- 0
lib/worker/chronic.ex View File

@@ -0,0 +1,46 @@
1
+defmodule Fortress.Worker.Chronic do
2
+  use GenServer
3
+  require Logger
4
+
5
+  @interval :timer.seconds(30)
6
+
7
+  def start_link() do
8
+    GenServer.start_link(__MODULE__, [], name: __MODULE__)
9
+  end
10
+
11
+  def init(state) do
12
+    {:ok, schedule(self(), state)}
13
+  end
14
+
15
+  def handle_call(:schedule, _from, state) do
16
+    {:reply, :ok, schedule(self(), state)}
17
+  end
18
+
19
+  def handle_call(:compute, _from, state) do
20
+    {:reply, :ok, execute(self(), state)}
21
+  end
22
+
23
+  def handle_info(:compute, state) do
24
+    {:noreply, execute(self(), state)}
25
+  end
26
+
27
+  def handle_info(_, state), do: {:noreply, state}
28
+
29
+  def schedule(pid, state) do
30
+    Process.send_after(pid, :execute, @interval)
31
+    state
32
+  end
33
+
34
+  # TODO: Move collection into [mod.func/1] in a Task.async_stream
35
+  def execute(pid, state) do
36
+    [
37
+      Task.async(fn ->
38
+        Fortress.Device.sweep()
39
+      end)
40
+    ]
41
+    |> Task.yield_many(timeout: @interval, on_timeout: :kill_task)
42
+    |> Stream.run()
43
+
44
+    schedule(pid, state)
45
+  end
46
+end

+ 1
- 0
mix.exs View File

@@ -74,6 +74,7 @@ defmodule Fortress.Mixfile do
74 74
       {:indieweb, git: "https://git.jacky.wtf/indieweb/elixir", branch: :develop, override: true},
75 75
       {:ueberauth_indieauth, git: "https://git.jacky.wtf/indieweb/ueberauth_indieauth", branch: :develop},
76 76
       {:ueberauth_github, "~> 0.7"},
77
+{:totpex, "~> 0.1.3"}
77 78
     ]
78 79
   end
79 80
 

+ 1
- 0
mix.lock View File

@@ -61,6 +61,7 @@
61 61
   "tesla_cache": {:hex, :tesla_cache, "0.1.2", "ef5a23c10c146fffc3c2c2a1e8fbff45a64fd28447b73112d955c92e7bf0b21a", [:mix], [{:cachex, "~> 2.1", [hex: :cachex, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm"},
62 62
   "tesla_request_id": {:hex, :tesla_request_id, "0.2.0", "9745364ed3c850864fb2744daefb9b7104fe7f1efcf34eb350c61da805de2a46", [:mix], [{:tesla, "~> 1.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm"},
63 63
   "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
64
+  "totpex": {:hex, :totpex, "0.1.3", "ae022eab70e8e230a0a65300b0d0dd67f510e0b7243c0d608a827c7b22ca6b51", [:mix], [], "hexpm"},
64 65
   "tzdata": {:hex, :tzdata, "1.0.2", "6c4242c93332b8590a7979eaf5e11e77d971e579805c44931207e32aa6ad3db1", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
65 66
   "ueberauth": {:hex, :ueberauth, "0.6.2", "25a31111249d60bad8b65438b2306a4dc91f3208faa62f5a8c33e8713989b2e8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
66 67
   "ueberauth_github": {:hex, :ueberauth_github, "0.7.0", "637067c5500f7b13c18caca3db66d09eba661524e0d0e9518b54151e99484bad", [:mix], [{:oauth2, "~> 0.9", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.4", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm"},

+ 18
- 0
priv/repo/migrations/20191120110918_create_devices.exs View File

@@ -0,0 +1,18 @@
1
+defmodule Fortress.Repo.Migrations.CreateDevices do
2
+  use Ecto.Migration
3
+
4
+  def change do
5
+    create table(:devices, primary_key: false) do
6
+      add :id, :binary_id, primary_key: true
7
+      add :key, :text
8
+      add :last_used_at, :utc_datetime
9
+      add :name, :string
10
+      add :site, references(:sites, on_delete: :nothing, type: :string, column: :url)
11
+
12
+      timestamps()
13
+    end
14
+
15
+    create unique_index(:devices, [:key])
16
+    create index(:devices, [:site])
17
+  end
18
+end

+ 10
- 0
priv/repo/migrations/20191120113102_add_totp_secret_to_devices.exs View File

@@ -0,0 +1,10 @@
1
+defmodule Fortress.Repo.Migrations.AddTotpSecretToDevices do
2
+  use Ecto.Migration
3
+
4
+  def change do
5
+
6
+    alter table(:devices) do
7
+      add :otp_secret, :string, null: true
8
+    end
9
+  end
10
+end

+ 9
- 0
priv/repo/migrations/20191120114409_add_pairing_code_to_devices.exs View File

@@ -0,0 +1,9 @@
1
+defmodule Fortress.Repo.Migrations.AddPairingCodeToDevices do
2
+  use Ecto.Migration
3
+
4
+  def change do
5
+    alter table(:devices) do
6
+      add :pairing_code, :string 
7
+    end
8
+  end
9
+end

+ 19
- 0
web/controllers/client.ex View File

@@ -0,0 +1,19 @@
1
+defmodule Fortress.Web.Api.ClientController do
2
+  use Fortress.Web, :controller
3
+
4
+  def pair(conn, params) do
5
+    case Fortress.Device.park(params["publicKey"]) do
6
+      {:ok, code: code} ->
7
+        json(conn, %{
8
+          pairing_code: code
9
+        })
10
+
11
+      {:error, _error} ->
12
+        conn
13
+        |> Explode.unprocessible_entity()
14
+    end
15
+  end
16
+
17
+  def request(conn, params)
18
+  def invoke(conn, params)
19
+end

+ 13
- 0
web/controllers/device.ex View File

@@ -0,0 +1,13 @@
1
+defmodule Fortress.Web.DeviceController do
2
+  use Fortress.Web, :controller
3
+
4
+  def new(conn, params), do: render(conn, "new.html")
5
+  def bond(conn, %{"code" => code}) do
6
+    user = owner_current_resource(conn) |> Apex.ap
7
+     owner_current_token(conn) |> Apex.ap
8
+    case Fortress.Device.pair(code, user.url) do
9
+      {:ok, device} -> render(conn, "bond.html")
10
+      {:error, error} -> conn |> put_flash(:error, "Bond failed.") |> render("new.html")
11
+    end
12
+  end
13
+end

+ 4
- 0
web/controllers/oauth2.ex View File

@@ -34,6 +34,7 @@ defmodule Fortress.Web.OAuth2Controller do
34 34
       {:ok, site} <- Fortress.Repo.Site.upsert(hcard)
35 35
     ) do
36 36
     # TODO: Destroy state.
37
+      Fortress.Repo.SiteProvider.clear_providers_for(user_url, Atom.to_string(auth.provider))
37 38
       Fortress.Repo.SiteProvider.bump_last_use(user_url, Atom.to_string(auth.provider))
38 39
       conn
39 40
       |> delete_session(:requesting_user)
@@ -51,4 +52,7 @@ defmodule Fortress.Web.OAuth2Controller do
51 52
         |> redirect(to: "/")
52 53
     end
53 54
   end
55
+
56
+  defp get_provider_url(auth)
57
+  defp get_provider_url(auth), do: auth.info.urls.url
54 58
 end

+ 14
- 0
web/helpers/auth.ex View File

@@ -60,6 +60,20 @@ defmodule Fortress.Web.Helpers.Authentication do
60 60
     end
61 61
   end
62 62
 
63
+  def owner_current_token(conn) do
64
+    case FGPlug.current_token(conn, key: :owner) do
65
+      nil -> {:error, :no_token}
66
+      token -> {:ok, token}
67
+    end
68
+  end
69
+
70
+  def owner_current_resource(conn) do
71
+    case FGPlug.current_resource(conn, key: :owner) do
72
+      nil -> {:error, :no_resource}
73
+      resource -> {:ok, resource}
74
+    end
75
+  end
76
+
63 77
   def revoke_token(token) do
64 78
     Fortress.Guardian.revoke(token)
65 79
   end

+ 14
- 3
web/router.ex View File

@@ -57,9 +57,20 @@ defmodule Fortress.Web.Router do
57 57
     get("/token", Indie.TokenApiController, :validate)
58 58
   end
59 59
 
60
-  scope "/api", Fortress.Web do
60
+  scope "/devices", Fortress.Web do
61
+    pipe_through([:browser, :owner_auth, :requires_owner_auth])
62
+    get("/new", DeviceController, :new)
63
+    put("/bond", DeviceController, :bond)
64
+  end
65
+
66
+  scope "/api/client", Fortress.Web do
67
+    pipe_through([:api])
68
+    put("/pair", Api.ClientController, :pair)
69
+  end
70
+
71
+  scope "/api/client", Fortress.Web do
61 72
     pipe_through([:api, :owner_auth, :requires_owner_auth])
62
-    get("/client", Api.ClientController, :request)
63
-    post("/client", Api.ClientController, :invoke)
73
+    get("/", Api.ClientController, :request)
74
+    post("/", Api.ClientController, :invoke)
64 75
   end
65 76
 end

+ 1
- 0
web/templates/device/bond.html.eex View File

@@ -0,0 +1 @@
1
+Wow

+ 14
- 0
web/templates/device/new.html.eex View File

@@ -0,0 +1,14 @@
1
+<main class="w-100 bg-near-white near-black pa2">
2
+  <div class="flex flex-column items-start justify-start mw7 center pv3">
3
+    <h1 class="f2 measure fw5 dark-gray tracked-tight lh-title center tc">
4
+      Pair New Device
5
+    </h1>
6
+    <p class="lh-copy measure-wide center f5">
7
+      Open Fortress for your mobile desktop or desktop application and head to the
8
+      "Pairing" view. Enter the code that's on your desktop in the textbox below.
9
+    </p>
10
+    <%= form_for @conn, Routes.device_path(@conn, :bond), [method: :put], fn f ->  %>
11
+<%= text_input f, :code, class: "w-100 measure-wide input-reset bg-washed-yellow navy mv2 pa2 bw1 b--navy b--solid f4 f2-l code center" %>
12
+  <% end %>
13
+  </div>
14
+</main>

+ 1
- 1
web/templates/layout/app.html.eex View File

@@ -35,7 +35,7 @@
35 35
           <%= link "Apps", to: "#" %>
36 36
           <%= link "Code", to: "#" %>
37 37
           <%= link "Platforms", to: "#" %>
38
-          <%= if is_signed_in?(@conn), do: link("Sign Out", to: Routes.page_path(@conn, :signout)) %>
38
+          <%= if is_signed_in?(@conn), do: link("Sign Out", to: Routes.page_path(@conn, :signout), method: :delete) %>
39 39
       </div>
40 40
       </div>
41 41
       <div id="loader" class="absolute right-2 top-2 br-pill shadow-2 bg-dark-gray near-white pa3 dn">

+ 3
- 0
web/views/device.ex View File

@@ -0,0 +1,3 @@
1
+defmodule Fortress.Web.DeviceView do
2
+  use Fortress.Web, :view
3
+end

+ 1
- 1
web/views/live/homepage_auth_view.ex View File

@@ -50,6 +50,6 @@ defmodule Fortress.Web.HomepageAuthLiveView do
50 50
 
51 51
   def handle_event("begin-signin", _params, socket) do
52 52
     url = socket.assigns[:scanner]["hcard"]["url"]
53
-    {:stop, socket |> redirect(to: Routes.resolver_path(socket, :present, %{url: url}))}
53
+    {:stop, socket |> redirect(to: Routes.resolver_path(socket, :present, %{me: url}))}
54 54
   end
55 55
 end

Loading…
Cancel
Save