Phoenix Todos - Authorized Sockets

This post is written as a set of Literate Commits. The goal of this style is to show you how this program came together from beginning to end.

Each commit in the project is represented by a section of the article. Click each section's header to see the commit on Github, or check out the repository and follow along.

Written by Pete Corey on Nov 9, 2016.

Authenticated Sockets

Now that we’ve implemented the bulk of the unauthenticated functionality in our application, we need to turn our attention to authenticated functionality.

To do that, we’ll need to authenticate the channel we’re using to communicate with the client. We can do this with a custom connect function that verified the user’s provided guardian_token:


def connect(%{"guardian_token" => jwt}, socket) do
  case sign_in(socket, jwt) do
    {:ok, authed_socket, guardian_params} ->
      {:ok, authed_socket}
    _ ->
      {:ok, socket}
  end
end

If the user is authenticated correctly, we’ll swap their socket out for an auth_socket, which can be used to access the current user’s object and claims.

If the user doesn’t provide a guardian_token, we’ll fall back to our old connect function:


  def connect(_params, socket) do
    {:ok, socket}
  end

All of the old functionality will continue to work as expected.

web/channels/user_socket.ex

... use Phoenix.Socket + import Guardian.Phoenix.Socket ... # performing token verification on connect. + def connect(%{"guardian_token" => jwt}, socket) do + case sign_in(socket, jwt) do + {:ok, authed_socket, _guardian_params} -> + {:ok, authed_socket} + _ -> + {:ok, socket} + end + end + def connect(_params, socket) do

Guardian Token

Now that our socket connection is expecting a guardian_token parameter, we need to supply it in our connectSocket action.

web/static/js/actions/index.js

... params: { - token: jwt + guardian_token: jwt }

Connect Socket Thunk

Because our entire socket connection will be authenticated or unauthenticated, we need to prepare ourselves to re-establish the connection every time we log in/out.

To start, we’ll need to clear out our local set of lists every time we connect to the socket.

We also need to trigger a call to our joinListsChannel thunk every time we connect.

web/static/js/actions/index.js

... export function connectSocket(jwt) { - let socket = new Socket("/socket", { - params: { - guardian_token: jwt - } - }); - socket.connect(); - return { type: CONNECT_SOCKET, socket }; + return (dispatch, getState) => { + let socket = new Socket("/socket", { + params: { + guardian_token: jwt + } + }); + socket.connect(); + dispatch({ type: CONNECT_SOCKET, socket }); + dispatch(joinListsChannel("lists.public")); + }; }

web/static/js/app.js

... store.dispatch(connectSocket(store.getState().jwt)); -store.dispatch(joinListsChannel("lists.public"));

web/static/js/reducers/index.js

... case CONNECT_SOCKET: - return Object.assign({}, state, { socket: action.socket }); + return Object.assign({}, state, { socket: action.socket, lists: [] }); case JOIN_LISTS_CHANNEL_SUCCESS:

Reconnect

Now we’ll reconnect to our socket every time a user signs in, signs up, or signs out. This ensures that the socket connection is always properly authenticated.

These changes also introduced a few small bugs which we quickly fixed.

web/static/js/actions/index.js

... dispatch(signUpSuccess(res.user, res.jwt)); + dispatch(connectSocket(res.jwt)); return true; ... dispatch(signOutSuccess()); + dispatch(connectSocket(res.jwt)); return true; ... dispatch(signInSuccess(res.user, res.jwt)); + dispatch(connectSocket(res.jwt)); return true;

web/static/js/layouts/App.jsx

... import { signOut, createList } from "../actions"; +import _ from "lodash"; ... const list = _.find(this.props.lists, list => list.id == this.props.params.id); - if (list.user_id) { + if (list && list.user_id) { const publicList = _.find(this.props.list, list => !list.user_id);

Fetching All Lists

To make things simpler, and to show off a feature of Phoenix, I’ve decided to merge the "lists.public" and "lists.private" publications into a single channel: "lists".

This channel will return all lists accessible by the current user, based on their authenticated socket.

We replaced the List.public function with List.all, which takes in a user_id. When user_id is nil, we return all public lists, as before. However, when user_id isn’t nil, we return all lists owned by that user (^user_id == list.user_id), and all public lists.

test/models/list_test.exs

... - test "public" do + test "all" do user = User.changeset(%User{}, %{ ... }) - Repo.insert!(%List{ + |> Repo.preload(:todos) + private = Repo.insert!(%List{ name: "private", ... }) + |> Repo.preload(:todos) - lists = List |> List.public |> Repo.all + public_lists = List |> List.all(nil) |> Repo.all |> Repo.preload(:todos) + all_lists = List |> List.all(user.id) |> Repo.all |> Repo.preload(:todos) - assert lists == [public] + assert public_lists == [public] + assert all_lists == [public, private] end

web/channels/list_channel.ex

... - def join("lists.public", _message, socket) do - lists = List |> List.public |> Repo.all + defp get_user_id(socket) do + case Guardian.Phoenix.Socket.current_resource(socket) do + user -> + user.id + _ -> + nil + end + end + + def join("lists", _message, socket) do + lists = List |> List.all(get_user_id(socket)) |> Repo.all {:ok, lists, socket}

web/channels/user_socket.ex

... # channel "rooms:*", PhoenixTodos.RoomChannel - channel "lists.public", PhoenixTodos.ListChannel + channel "lists", PhoenixTodos.ListChannel

web/models/list.ex

... - def public(query) do + def all(query, nil) do from list in query, ... + def all(query, user_id) do + from list in query, + where: ^user_id == list.user_id or is_nil(list.user_id), + order_by: list.inserted_at, + preload: [:todos] + end + def findByName(query, name) do

web/static/js/actions/index.js

... dispatch({ type: CONNECT_SOCKET, socket }); - dispatch(joinListsChannel("lists.public")); + dispatch(joinListsChannel("lists")); };

Final Thoughts

In hashing out this authorization scheme, I’ve realized there are lots of problems with this approach. Splitting communication across both WebSockets and REST endpoints creates lots of confusion around a user’s authorization state.

In hindsight, it would have been better to do everything over WebSockets and forget the REST user and sessions endpoints altogether. I’ll be sure to write up my thoughts around the problems with this kind of authorization and how to to it better in the future.

Next week, we should be able to finish up all authenticated functionality and finish up the Meteor to Phoeix migration project!