diff --git a/assets/js/user_socket.js b/assets/js/user_socket.js new file mode 100644 index 0000000..7937d42 --- /dev/null +++ b/assets/js/user_socket.js @@ -0,0 +1,23 @@ +// NOTE: The contents of this file will only be executed if +// you uncomment its entry in "assets/js/app.js". + +// Bring in Phoenix channels client library: +import {Socket} from "phoenix" + +// And connect to the path in "lib/birdy_chat_web/endpoint.ex". We pass the +// token for authentication. +// +// Read the [`Using Token Authentication`](https://hexdocs.pm/phoenix/channels.html#using-token-authentication) +// section to see how the token should be used. +let socket = new Socket("/socket", {authToken: window.userToken}) +socket.connect() + +// Now that you are connected, you can join channels with a topic. +// Let's assume you have a channel with a topic named `room` and the +// subtopic is its id - in this case 42: +let channel = socket.channel("room:42", {}) +channel.join() + .receive("ok", resp => { console.log("Joined successfully", resp) }) + .receive("error", resp => { console.log("Unable to join", resp) }) + +export default socket diff --git a/lib/birdy_chat_web/channels/server_channel.ex b/lib/birdy_chat_web/channels/server_channel.ex new file mode 100644 index 0000000..cb83c2c --- /dev/null +++ b/lib/birdy_chat_web/channels/server_channel.ex @@ -0,0 +1,35 @@ +defmodule BirdyChatWeb.ServerChannel do + use BirdyChatWeb, :channel + + @impl true + def join("server:" <> server_id, payload, socket) do + if authorised?(payload, server_id) do + {:ok, socket} + else + {:error, %{reason: "unauthorised"}} + end + end + + @impl true + def handle_in("new_message", {:binary, message}, socket) do + result = :erlang.binary_to_term(message, [:safe]) + + # TODO: Add validation that this is the right server. + case BirdyChat.Message.validate(result) do + {:ok, changeset} -> + case BirdyChat.MessageWriter.write(changeset.changes) do + :ok -> + {:reply, {:ok, {:binary, message}}, socket} + end + end + end + + # Simple token-based authentication. Servers should use the same Phoenix secret key so they will + # have the basis for generating tokens. + defp authorised?(%{"token" => token}, server_id) do + case Phoenix.Token.verify(BirdyChatWeb.Endpoint, "serverAuth", token, max_age: 86400) do + {:ok, id} -> id == server_id + {:error, :invalid} -> false + end + end +end diff --git a/lib/birdy_chat_web/channels/server_socket.ex b/lib/birdy_chat_web/channels/server_socket.ex new file mode 100644 index 0000000..8843d1b --- /dev/null +++ b/lib/birdy_chat_web/channels/server_socket.ex @@ -0,0 +1,44 @@ +defmodule BirdyChatWeb.ServerSocket do + use Phoenix.Socket + + # A Socket handler + # + # It's possible to control the websocket connection and + # assign values that can be accessed by your channel topics. + + ## Channels + + channel "server:*", BirdyChatWeb.ServerChannel + + # Socket params are passed from the client and can + # be used to verify and authenticate a user. After + # verification, you can put default assigns into + # the socket that will be set for all channels, ie + # + # {:ok, assign(socket, :user_id, verified_user_id)} + # + # To deny connection, return `:error` or `{:error, term}`. To control the + # response the client receives in that case, [define an error handler in the + # websocket + # configuration](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#socket/3-websocket-configuration). + # + # See `Phoenix.Token` documentation for examples in + # performing token verification on connect. + @impl true + def connect(_params, socket, _connect_info) do + {:ok, socket} + end + + # Socket IDs are topics that allow you to identify all sockets for a given user: + # + # def id(socket), do: "user_socket:#{socket.assigns.user_id}" + # + # Would allow you to broadcast a "disconnect" event and terminate + # all active sockets and channels for a given user: + # + # Elixir.BirdyChatWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) + # + # Returning `nil` makes this socket anonymous. + @impl true + def id(socket), do: "user_socket:#{socket.assigns.server_id}" +end diff --git a/lib/birdy_chat_web/endpoint.ex b/lib/birdy_chat_web/endpoint.ex index 72a9ccf..972f7ee 100644 --- a/lib/birdy_chat_web/endpoint.ex +++ b/lib/birdy_chat_web/endpoint.ex @@ -15,6 +15,10 @@ defmodule BirdyChatWeb.Endpoint do websocket: [connect_info: [session: @session_options]], longpoll: [connect_info: [session: @session_options]] + socket "/socket", BirdyChatWeb.ServerSocket, + websocket: true, + longpoll: false + # Serve at "/" the static files from "priv/static" directory. # # When code reloading is disabled (e.g., in production), diff --git a/test/birdy_chat_web/channels/server_channel_test.exs b/test/birdy_chat_web/channels/server_channel_test.exs new file mode 100644 index 0000000..5a4fe8d --- /dev/null +++ b/test/birdy_chat_web/channels/server_channel_test.exs @@ -0,0 +1,81 @@ +defmodule BirdyChatWeb.ServerChannelTest do + use BirdyChatWeb.ChannelCase + + setup do + server_id = "test2" + token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "serverAuth", server_id) + + path = Application.app_dir(:birdy_chat, ["priv", "messages", "test-user.txt"]) + on_exit(fn -> File.rm(path) end) + + {:ok, _, socket} = + BirdyChatWeb.ServerSocket + |> socket("server_socket", %{server_id: server_id}) + |> subscribe_and_join(BirdyChatWeb.ServerChannel, "server:#{server_id}", %{token: token}) + + %{socket: socket} + end + + describe "authorization" do + test "requires a signed token to join a channel" do + server_id = "test2" + token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "fakeAuth", server_id) + + result = + BirdyChatWeb.ServerSocket + |> socket("server_socket", %{server_id: server_id}) + |> subscribe_and_join(BirdyChatWeb.ServerChannel, "server:#{server_id}", %{token: token}) + + assert result == {:error, %{reason: "unauthorised"}} + end + + test "requires server id in token and in channel to match" do + server_id = "test2" + token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "serverAuth", server_id) + + result = + BirdyChatWeb.ServerSocket + |> socket("server_socket", %{server_id: server_id}) + |> subscribe_and_join(BirdyChatWeb.ServerChannel, "server:fake", %{token: token}) + + assert result == {:error, %{reason: "unauthorised"}} + end + end + + describe "new_message" do + test "receives message from other server and saves it locally", %{socket: socket} do + message = %{from: "test2-user", to: "test-user", message: "123"} + bin = :erlang.term_to_binary(message) + ref = push(socket, "new_message", {:binary, bin}) + assert_reply ref, :ok, {:binary, ^bin} + + path = Application.app_dir(:birdy_chat, ["priv", "messages", "test-user.txt"]) + contents = File.read!(path) + assert contents == "test2-user: 123\n" + end + + test "blows up when sent payload is not safe to decode", %{socket: socket} do + # This unsafe binary is taken from erlang docs directly: + bin = <<131, 119, 8, "tjenixen">> + Process.flag(:trap_exit, true) + push(socket, "new_message", {:binary, bin}) + + assert_receive {:EXIT, _pid, + {:badarg, + [ + {:erlang, :binary_to_term, + [<<131, 119, 8, 116, 106, 101, 110, 105, 120, 101, 110>>, [:safe]], + [error_info: %{module: :erl_erts_errors}]}, + {BirdyChatWeb.ServerChannel, :handle_in, 3, + [file: ~c"lib/birdy_chat_web/channels/server_channel.ex", line: 15]}, + {Phoenix.Channel.Server, :handle_info, 2, + [file: ~c"lib/phoenix/channel/server.ex", line: 332]}, + {:gen_server, :try_handle_info, 3, + [file: ~c"gen_server.erl", line: 2434]}, + {:gen_server, :handle_msg, 3, [file: ~c"gen_server.erl", line: 2420]}, + {:proc_lib, :init_p_do_apply, 3, [file: ~c"proc_lib.erl", line: 333]} + ]}}, + 100 + end + end +end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex new file mode 100644 index 0000000..0cd840c --- /dev/null +++ b/test/support/channel_case.ex @@ -0,0 +1,34 @@ +defmodule BirdyChatWeb.ChannelCase do + @moduledoc """ + This module defines the test case to be used by + channel tests. + + Such tests rely on `Phoenix.ChannelTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use BirdyChatWeb.ChannelCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with channels + import Phoenix.ChannelTest + import BirdyChatWeb.ChannelCase + + # The default endpoint for testing + @endpoint BirdyChatWeb.Endpoint + end + end + + setup _tags do + :ok + end +end