DEV Community

Sophie DeBenedetto
Sophie DeBenedetto

Posted on

JWT Auth with Phoenix and React Router 4

Since I clearly cannot get enough of JWT authentication, here's a look at how to use it to authenticate your shiny new Phoenix API with a React + Redux front-end application, using React Router 4.

In this post, we'll cover:

  • Using React Router 4 to set up both regular and authenticated routes.
  • Using React Router's routerMiddleware to teach our store how to handle actions provided to us by React router.
  • Building a simple Phoenix API endpoint for authentication with the help of Comeonin and Guardian.
  • Using React to establish a connection to a Phoenix websocket and channel.
  • Using a Guardian Plug to authenticate incoming API requests from React using the JWT.

Configuring The Routes

First things first, we'll configure our routes and append that configuration to the DOM to render our component tree.

For the purposes of this article, let's say that we're building a chatting application in which users can visit an index of chatrooms, /chats, and enter a chatroom, chats/:id, to start chatting

# web/static/js/routes/index.js

import React               from 'react';
import { Route, Redirect } from 'react-router-dom'
import App                 from '../containers/app';
import Navigation          from '../views/shared/nav';
import RegistrationsNew    from '../views/registrations/new';
import SessionsNew         from '../views/sessions/new';
import Chats               from '../views/chats';
import Actions             from '../actions/sessions';

export default function configRoutes() {
  return (
    <div>
      <Navigation />
      <Route exact path="/" component={App} />
      <Route path="/sign_up" component={RegistrationsNew} />
      <Route path="/sign_in" component={SessionsNew} />
      <AuthenticatedRoute path="/chats" component={Chats} />
    </div>
  );
}

const AuthenticatedRoute = ({ component: Component, ...rest }) => (
  <Route {...rest} render={props => (
    localStorage.getItem('phoenixAuthToken') ? (
      <Component {...props}/>
    ) : (
      <Redirect to={{
        pathname: '/sign_in',
        state: { from: props.location }
      }}/>
    )
  )}/>
)
Enter fullscreen mode Exit fullscreen mode

If you're familiar with earlier versions of React Router, much of this code probably looks familiar.

We've defined a function configRoutes, that uses React Router DOM's Route component to define a set of routes. We map each path to a component to render, and we import our components at the top of the file.

We've defined the following routes:

  • /, the root path, which points to our container component, App.
  • /sign_up, which points to the component that houses our registration form.
  • /sign_in, pointing to the component that houses our sign in form.
  • /chats, pointing to the chat index component. This route is our protected, or authenticated route.

Let's take a closer look at that authenticated route now.

Defining an Authenticated Route

Our authenticated route is really just a functional component. It is invoked with props that include a key of component, set to the Chats component that we passed in.

Our functional component returns a Route component. The render() function of this Route component is responsible for rendering the Chats component from props, or redirecting.

Let's take a closer look at this render() function:

props => (
  localStorage.getItem('phoenixAuthToken') ? (
    <Component {...props}/>   
  ) : (
    <Redirect to={{
      pathname: '/sign_in',
      state: { from: props.location }
    }}/>
  )
)
Enter fullscreen mode Exit fullscreen mode

Our function determines whether or not we have an authenticated user based on the presence or absence of the phoenixAuthToken key in localStorage. Later, we'll build out the functionality of storing the JWT we receive from Phoenix in localStorage.

If a token is present, we'll go ahead and call the component that was passed into our Route as a prop, the Chats component.

If no token is found, we'll use the Redirect component from React Router DOM to enact a redirect.

And that's it! Now, we'll take our route configuration and append it to the DOM with ReactDOM, thereby appending our component tree to the DOM.

Configuring The Store and Router Component

import React from 'react';
import ReactDOM from 'react-dom';
import { createStore, applyMiddleware } from 'redux'
import { Provider} from 'react-redux'
import thunk from 'redux-thunk'
import createHistory from 'history/createBrowserHistory'
import {
  ConnectedRouter as Router,
  routerMiddleware
} from 'react-router-redux'
import {
  Route,
  Link
} from 'react-router-dom'

import configRoutes from './routes'
import rootReducer from './reducers'

const history = createHistory()
const rMiddleware = routerMiddleware(history)

const store = createStore(
  rootReducer,
  applyMiddleware(thunk, rMiddleware)
)


ReactDOM.render(
  <Provider store={store}>
    <Router history={history}>
      <div>
        {configRoutes()}
      </div>
    </Router>
  </Provider>,
  document.getElementById('main_container')
);

Enter fullscreen mode Exit fullscreen mode

There are a few things to point out here.

First, we're using React Router's routerMiddleware. React Router gives us access to a set of action creator functions with which to manipulate browser history:

  • push(location)
  • replace(location)
  • go(number)
  • goBack()
  • goForward()

We'll use push later to redirect after we sign in a user.

Out of the box, however, the Redux store doesn't know how to handle the dispatch of these actions. That's where the routerMiddleware comes in. We create an instance of our routerMiddleware by invoking the routerMiddleware function with an argument of our browser history instance.

Then, we pass this middleware instance to our store via the applyMiddlware function. Now, when we dispatch any of the actions listed above, the store will handle them by applying them to our browser history.

It's important to note that we still need to pass our browser history instance to our Router. This will make sure that our routes sync up with the browser history's location and the store at the same time.

Now that we have our routes set up, let's build the authorization flow.

The Sign In Component

Our sign in form will live in our sessions/new.js component. Let's build it out:

# /views/sessions/new.js

import React   from 'react';
import { connect }          from 'react-redux';
import { Link }             from 'react-router-dom';
import Actions              from '../../actions/sessions';

class SessionsNew extends React.Component {
  handleSubmit(e) {
    e.preventDefault();
    const { dispatch } = this.props;

    const data = {
      email: this.refs.email.value,
      password: this.refs.password.value
    };

    dispatch(Actions.signIn(data));
  }

  render() {
    const { errors } = this.props;

    return (
      <div className="container">
        <div className="container">
          <form 
            className="form-horizontal" 
            onSubmit={::this.handleSubmit}>
            <fieldset>
              <legend>Sign In</legend>
              <div className="form-group">
                <label className="col-lg-2">email</label>
                <div className="col-lg-10">
                  <input 
                    className="form-control" 
                    ref="email" 
                    id="user_email" 
                    type="text" 
                    placeholder="email" required={true} />
                </div>
              </div>

              <div className="form-group">
                <label className="col-lg-2">password</label>
                <div className="col-lg-10">
                  <input 
                    className="form-control" 
                    ref="password" 
                    id="user_password" 
                    type="password" 
                    placeholder="password" required={true} />
                </div>
              </div>
              <br/>       
            <button type="submit">Sign in</button>
            </fieldset>
          </form>
          <Link to="/sign_up">Sign up</Link>
      </div>
      </div>
    );
  }
}

export default connect()(SessionsNew)
Enter fullscreen mode Exit fullscreen mode

Our form is pretty simple, it has a field for the user's email and a field for the user's password. On the submission of the form, we dispatch an action that will send a POST request to the sign in route of our Phoenix API.

Let's build out that action now.

The Sign In Action

# /actions/sessions.js

import { push }      from 'react-router-redux';
import Constants     from '../constants';
import { Socket }    from 'phoenix';
import { httpPost }  from '../utils';


const Actions = {
  signIn: (creds) => {
    return dispatch => {
      const data = {
        session: creds,
      };
      httpPost('/api/v1/sessions', data)
      .then((response) => {
        localStorage.setItem('phoenixAuthToken', 
          response.jwt);
        setCurrentUser(dispatch, response.user);
        dispatch(push('/challenges'));
      })
      .catch((error) => {
        error.response.json()
        .then((errorJSON) => {
          dispatch({
            type: Constants.SESSIONS_ERROR,
            error: errorJSON.error,
          });
        });
      });
    };
  }
}

export default Actions
Enter fullscreen mode Exit fullscreen mode

Here, we define our Actions constant to implement a function, signIn(). We also use this same file to define a helper function, setCurrentUser().

The signIn() function relies on a tool we defined in another file, httpPost(), to make our POST request to the sign in endpoint of our Phoenix API.

The httpPost() function relies on Fetch to make web requests:

# web/utils/index.js

import fetch        from 'isomorphic-fetch';
import { polyfill } from 'es6-promise';

const defaultHeaders = {
  Accept: 'application/json',
  'Content-Type': 'application/json',
};

function headers() {
  const jwt = localStorage.getItem('phoenixAuthToken');

  return { ...defaultHeaders, Authorization: jwt };
}

export function checkStatus(response) {
  if (response.ok) {
    return response;
  } else {
    var error      = new Error(response.statusText);
    error.response = response;
    throw error;
  }
}

export function parseJSON(response) {
  return response.json();
}


export function httpPost(url, data) {
  const body = JSON.stringify(data);

  return fetch(url, {
    method: 'post',
    headers: headers(),
    body: body,
  }).then(checkStatus)
    .then(parseJSON);
}
Enter fullscreen mode Exit fullscreen mode

Note: This file will grow to include all of our HTTP requests to our API, and rely on the headers() function to build authentication headers using the token we will store in localStorage once we authenticate our user.

So, we use the httpPost function to make our authentication request to the API, and if that request is a success, we grab the jwt key from the response body and store it in localStorage. We'll actually build out this endpoint soon, but for now we will assume that it exists and returns a successful response body of:

{
  jwt: <some token>,
  user: <serialized user>
}
Enter fullscreen mode Exit fullscreen mode

Let's take a closer look at the code in our signIn() function that is responsible for this action:

localStorage.setItem('phoenixAuthToken', response.jwt);
setCurrentUser(dispatch, response.user);
dispatch(push('/challenges'));
Enter fullscreen mode Exit fullscreen mode

After we set the phoenixAuthToken in localStorage, we invoke our helper function, setCurrentUser, and use the dispatch function to invoke a route change. This route change is enacted with the help of the push action creator function from React Router Redux. (Remember when we used the routerMiddleware to enable our store to handle the push action?)

We're almost ready to take a closer look at the setCurrentUser() function. But first, let's build out the authentication endpoint of our Phoenix API.

The Sign In API Endpoint

Phoenix Authorization Dependencies

In order to authenticate users, we'll use the Comeonin library. In order to generate a JWT token for our user, we'll rely on the Guardian library.

Let's add these dependencies to our mix.exs file and make sure to start up the Comeonin application when our app starts.

# mix.exs
...

def application do
  [
    mod: {PhoenixPair, []},
    applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext, :phoenix_ecto, :postgrex, :comeonin]
  ]
end

...
defp deps do
  [{:phoenix, "~> 1.2.1"},
   {:phoenix_pubsub, "~> 1.0"},
   {:phoenix_ecto, "~> 3.0"},
   {:postgrex, ">= 0.0.0"},
   {:phoenix_html, "~> 2.6"},
   {:phoenix_live_reload, "~> 1.0", only: :dev},
   {:gettext, "~> 0.11"},
   {:cowboy, "~> 1.0"},
   {:comeonin, "~> 2.0"},
   {:guardian, "~> 0.9.0"}]
end
Enter fullscreen mode Exit fullscreen mode

Defining the Route

We'll scope our API endpoints under /api/v1, and define our sign in route like this:

# /web/router.ex

  scope "/api", PhoenixPair do
    pipe_through :api

    scope "/v1" do
      post "/sessions", SessionsController, :create
    end
  end
Enter fullscreen mode Exit fullscreen mode

Defining the Controller

The SessionsController will implement a create function, that contains the code for authorizing the user.

# web/controllers/api/v1/sessions_controller.ex

defmodule PhoenixPair.SessionsController do 
  use PhoenixPair.Web, :controller

  alias PhoenixPair.{Repo, User}

  plug :scrub_params, "session" when action in [:create]

  def create(conn, %{"session" => session_params}) do
    case PhoenixPair.Session.authenticate(session_params) do
    {:ok, user} ->
      {:ok, jwt, _full_claims} = user 
        |> Guardian.encode_and_sign(:token)
      conn
        |> put_status(:created)
        |> render("show.json", jwt: jwt, user: user)
    :error ->
      conn
      |> put_status(:unprocessable_entity)
      |> render("error.json")
    end
  end

  def unauthenticated(conn, _params) do 
    conn
    |> put_status(:forbidden)
    |> render(PhoenixPair.SessionsView, "forbidden.json", 
      error: "Not Authenticated!")
  end
end
Enter fullscreen mode Exit fullscreen mode

Authenticating the User

Our create function relies on a helper module, PhoenixPair.Session to authenticate the user given the email and password present in params.

# web/services/session.ex

defmodule PhoenixPair.Session do
  alias PhoenixPair.{Repo, User}
  def authenticate(%{"email" => e, "password" => p}) do
    case Repo.get_by(User, email: e) do
      nil -> 
        :error
      user ->
        case verify_password(p, user.encrypted_password) do
          true ->
            {:ok, user}
          _ ->
            :error
        end
    end
  end

  defp verify_password(password, pw_hash) do
    Comeonin.Bcrypt.checkpw(password, pw_hash)
  end
end

Enter fullscreen mode Exit fullscreen mode

This module implements a function, authenticate/1, which expects to be invoked with an argument of a map that pattern matches to a map with keys of "email" and "password".

It uses the email to look up the user via:


Repo.get_by(User, email: email)
Enter fullscreen mode Exit fullscreen mode

If no user is found our case statement with execute the nil -> clause and return the atom :error.

If a user is found, we'll call our verify_password helper function. This function uses Comeonin.Bcrypt.checkpw to validate the password. If this validation is successful, we will return the tuple {:ok, user}, where user is the User struct returned by our Repo.get_by query.

Generating a JWT

Back in our controller, if the call to .Session.authenticate returns the success tuple, {:ok, user}, we'll use Guardian to generate a JWT.

...
{:ok, jwt, _full_claims} = user 
   |> Guardian.encode_and_sign(:token)
   conn
     |> put_status(:created)
     |> render("show.json", jwt: jwt, user: user)
Enter fullscreen mode Exit fullscreen mode

If our call to Guardian.encode_and_sign(user, :token) was successful, we'll use our Session View to render the following JSON payload:

{jwt: jwt, user: user}
Enter fullscreen mode Exit fullscreen mode
# web/views/sessions_view.ex

defmodule PhoenixPair.SessionsView do
  use PhoenixPair.Web, :view

  def render("show.json", %{jwt: jwt, user: user}) do
    %{
      jwt: jwt,
      user: user
    }
  end

  def render("error.json", _) do
    %{error: "Invalid email or password"}
  end

  def render("forbidden.json", %{error: error}) do
    %{error: error}
  end
end
Enter fullscreen mode Exit fullscreen mode

If the call to .Session.authenticate was not successful, or if our attempt to use Guardian to generate a token was not successful, we will render an error instead.

Now that our endpoint is up and running, let's return to our React app and discuss how we will set the current user with a successful payload.

Setting the Current User

What does it mean to set the current user in a React and Phoenix app? We want to leverage the power of Phoenix channels to build real-time communication features for our user. So, when we "set the current user", we will need to establish a socket connection for that user, and connect that user to their very own Phoenix channel.

On the React side, we will store the current user's information in state, under the session key, under a key of currentUser:

# state
{
  session: 
    currentUser: {
      name: <a href="http://beatscodeandlife.ghost.io/">"Antoin Campbell"</a>, 
      email: "antoin5@5antoins.com"
    },
    ...
  ...
}
Enter fullscreen mode Exit fullscreen mode

So, our setCurrentUser() function, called in our signIn() action, should handle both of these responsibilities.

Establishing the Current User's Socket Connection

We'll import Socket from Phoenix, and use the Socket API to establish our user's socket connection.

import { Socket } from 'phoenix';

export function setCurrentUser(dispatch, user) {
  const socket = new Socket('/socket', {
    params: {token: localStorage.getItem('phxAuthToken') },
    logger: (kind, msg, data) => { console.log(`${kind}: 
      ${msg}`, data); },
  });

  socket.connect();

  const channel = socket.channel(`users:${user.id}`);
  if (channel.state != 'joined') {
    channel.join().receive('ok', () => {
      dispatch({
        type: Constants.SOCKET_CONNECTED,
        currentUser: user,
        socket: socket,
        channel: channel,
      });
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

Let's break this down.

  • First, we instantiate a new instance of Socket via:

const socket = new Socket('/socket', {
  params: {token: localStorage.getItem('phxAuthToken')},
  logger: (kind, msg, data) => { console.log(`${kind}: 
    ${msg}`, data); 
}
Enter fullscreen mode Exit fullscreen mode

Then, we invoke the connect function on that instance:

socket.connect()
Enter fullscreen mode Exit fullscreen mode

This has the effect of invoking the connect function of our UserSocket, with params of %{"token" => token}. We'll need to define that socket to implement the connect function:

web/channels/user_socket.ex

defmodule PhoenixPair.UserSocket do
  use Phoenix.Socket
  alias PhoenixPair.{Repo, User, GuardianSerializer, Session}

  ## Channels
  channel "users:*", PhoenixPair.UsersChannel

  ## Transports
  transport :websocket, Phoenix.Transports.WebSocket
  transport :longpoll, Phoenix.Transports.LongPoll

  def connect(%{"token" => token}, socket) do
    case Guardian.decode_and_verify(token) do
      {:ok, claims} ->
        case GuardianSerializer.from_token(claims["sub"]) do
          {:ok, user} ->
            {:ok, assign(socket, :current_user, user)}
          {:error, _reason} ->
            :error
        end
      {:error, _reason} ->
        :error
    end
  end

  def connect(_params, socket), do: :error

  def id(socket) do
    "users_socket:{socket.assigns.current_user.id}"
  end
end
Enter fullscreen mode Exit fullscreen mode

Our connect function uses Guardian to decode the JWT from params. If the decode was successful, we'll use Guardian again to pluck out the User struct from the deserialized token payload. Then, we'll assign that struct to the key of :current_user within our socket's storage system. This socket is shared by all additional channels we might open for this user. So, any future channels we build on this socket can access the current user via socket.assigns.current_user.

Our UserSocket also implements a connect function that does not match the pattern of expected params. This function will simply return :error.

def connect(_params, socket), do: :error
Enter fullscreen mode Exit fullscreen mode

Lastly, we define an id function, which returns the designation of this socket, named with the ID of our user:

def id(socket) do: 
  "users_socket:#{socket.assigns.current_user.id}"
end
Enter fullscreen mode Exit fullscreen mode

The socket id will allow us to identify all sockets for a given user, and therefore broadcast events through a specific user's socket. For example:

PhoenixPair.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})
Enter fullscreen mode Exit fullscreen mode

Now that our User Socket knows how to handle the calls to connect, let's go back to our React app's setCurrentUser() function and connect to the UsersChannel.

Connecting to the Users Channel

We'll define our UsersChannel to respond to a join function, and return the socket connection if the join was successful.

# web/channels/users_channel.ex

defmodule PhoenixPair.UsersChannel do 
  use PhoenixPair.Web, :channel

  def join("users:" <> user_id, _params, socket) do
    {:ok, socket} 
  end
end
Enter fullscreen mode Exit fullscreen mode

Then, we'll have our setCurrentUser function in React send a message to join this channel:

export function setCurrentUser(dispatch, user) {
  ...
  const channel = socket.channel(`users:${user.id}`);
  if (channel.state != 'joined') {
    channel.join().receive('ok', () => {
      dispatch({
        type: Constants.SOCKET_CONNECTED,
        currentUser: user,
        socket: socket,
        channel: channel
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

We get our channel instance via
socket.channel(users:${user.id}). Then, we join the channel by calling channel.join(). This fires the join function we defined in our UsersChannel.

On to that function invocation, we chain a call to receive. The receive function which will be invoked when we get the "ok" response from our channel.

Once the channel has been successfully joined, we're ready to dispatch an action to our reducer to update state with our current user, as well as the socket and channel. We want to store these last two items in our React application's state so that we can use them to enact channel communications later on as we build out our chatting app.

Making Authenticated API Requests

Now that we're properly storing our current user in our React app's state, and our current user's JWT in localStorage, let's take a look at how we will make subsequent authenticated requests to our Phoenix API.

We've already defined a set of helper functions in web/static/js/utils/index.js that use Fetch to make API requests. These functions rely on a helper method, headers, to set the authorization header using the token from localStorage:

import React        from 'react';
import fetch        from 'isomorphic-fetch';
import { polyfill } from 'es6-promise';

const defaultHeaders = {
  Accept: 'application/json',
  'Content-Type': 'application/json',
};

function headers() {
  const jwt = localStorage.getItem('phoenixAuthToken');

  return { ...defaultHeaders, Authorization: jwt };
}

export function checkStatus(response) {
  if (response.ok) {
    return response;
  } else {
    var error = new Error(response.statusText);
    error.response = response;
    throw error;
  }
}

export function parseJSON(response) {
  return response.json();
}

export function httpGet(url) {

  return fetch(url, {
    headers: headers(),
  })
  .then(checkStatus)
  .then(parseJSON);
}

export function httpPost(url, data) {
  const body = JSON.stringify(data);

  return fetch(url, {
    method: 'post',
    headers: headers(),
    body: body,
  })
  .then(checkStatus)
  .then(parseJSON);
} 

...
Enter fullscreen mode Exit fullscreen mode

So, all of the requests we make to our Phoenix API using the functions we've defined here, httpPost, httpGet, etc., will include the JWT in the authorization header.

Now we have to teach our Phoenix controllers to authorize incoming requests using this header. Luckily, Guardian does a lot of this work for us.

Let's take a look at our ChatsController.

defmodule PhoenixPair.ChatsController do 
  use PhoenixPair.Web, :controller

  plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixPair.SessionsController

  alias PhoenixPair.{Repo, User, Challenge}

  def index(conn, _params) do
    challenges = Repo.all(Chat) 
    render(conn, "index.json", chats: chats)
  end
end
Enter fullscreen mode Exit fullscreen mode

This is the line that has all the authorization magic:

plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixPair.SessionsController
Enter fullscreen mode Exit fullscreen mode

This plug checks for a valid JWT in the authorization header.
If one isn't found, it invokes the unauthenticated function in the handler module. In our case, this is the PhoenixPair.SessionsController.unauthenticated function that we defined earlier.

We can add this plug to any and all authenticated controllers as we build out our app.

Conclusion

So far, I've found that React and Phoenix play really well together. I definitely approached this authentication feature with a little trepidation, not having worked with React Router 4 before or done any token-based auth in Phoenix.

However, integrating JWT authentication between our React front-end and our Phoenix API back-end was pretty seamless thanks to the tools provided by React Router and Guardian.

Happy coding!

Oldest comments (1)

Collapse
 
calier profile image
Calie Rushton

Thanks Sophie!

I'm refactoring one of my Flatiron projects to use Routes instead of A LOT of conditional formatting - the authenticated route part was exactly what I needed!