Token Auth with JWTs Part 2 - React + Redux

Token Auth with JWTs Part 2 - React + Redux

This is part 2 of a multi-part series on setting up user authentication in a MERN-stack application using JSON Web Tokens. If you haven't been following along, please visit the other posts for a full understanding of the current project:

  1. Authentication Basics
  2. Token Auth with JWTs Part 1 - Server Setup

Once you've gone through those, you should check out the part-2-react-auth-beginning branch to start from the same place we'll be starting from for this tutorial. To do that:

# Add and commit your code if you've been typing along
git add -A
git commit -m '<your message>'

# Whether you've been typing along or not, follow these steps:
git checkout part-2-react-auth-beginning

# cd into the "client" folder
npm install

# Just in case, cd back into the project's root folder
npm install

# Run the server
nodemon index.js

# In separate terminal window, run the react app from the client folder
cd client && npm start

Again, spend some time looking around the React code to make sure you understand everything before moving on. This tutorial assumes you've written at least smallish apps in React before.

As a recap, some things to note about the starter code:

  • We're using redux-thunk as middleware so we can asynchronously dispatch actions
  • We're using a slightly-modified version of the "ducks" redux organization
  • The parent index.js file in the redux folder is solely in charge of combining separated reducers and using the combined reducers to create and export the store.
  • We've added a "proxy" line to the client's package.json so that all outgoing HTTP requests will be proxied to port 5000. This way we can avoid any CORS issues.
    • If you make a change to this proxy, like if you chose to run your server on a port other than 5000, you'll need to manually stop and start your React dev server. Otherwise the change won't take effect.

Goal

Building off the todo app from part 1, we'll be adding the ability for users to sign up, login, logout, view their profiles, and add/remove todos.

First we'll go over how to write redux actions that will allow a user to login, sign up, and logout.

The process goes something like this:

  1. Make a POST request containing user's login credentials.
    • If the credentials are valid, the server will send a token and information about the user. We already built this functionality in part 1 of this series.
  2. Store token in local storage and (limited) user information in redux state.
  3. Send token with all outgoing requests in an HTTP header called Authorization.
  4. If the user logs out, remove the token and user info from local storage and redux state.

Getting Started

Our project contains a simple navbar and several views. Currently, our login and signup forms don't actually do anything except alert us with the info we just entered.

signup

The todos and profile links don't show us much. Since we haven't implemented a way to send a token to the routes we protected in express, we will get an authorization error if we try to view todos or add them.
error

Signing Up

Right now we only have a redux reducer and action creators for todos, so let's write one for authentication.

In the redux folder, create a new file called auth.js. Now your redux folder should look like this:

|_ redux/
    |_ auth.js
    |_ todos.js
    |_ index.js

In our redux/auth.js file, we need to write a function that makes a POST request to our /auth/signup endpoint containing our user credentials (chosen username and password):

import axios from "axios";

export function signup(userInfo) {
    return dispatch => {
        axios.post("/auth/signup", userInfo)
            .then(response => {
                // We'll come back to this to dispatch an action to the reducer instead of just this console.log
                console.log(response.data);
            })
            .catch(err => {
                console.error(err);
            })
    }
}

With this action creator written, now you will have to connect your SignupFormContainer so it can dispatch this action.

Open your SignupFormContainer (/src/main/Signup/index.js), and make the following additions:

...
import {connect} from "react-redux";
import {signup} from "../../redux/auth";

class SignupFormContainer extends Component {
...
    handleSubmit(e) {
        e.preventDefault();
        this.props.signup(this.state.inputs);
        this.clearInputs();
    }
...
}

export default connect(null, {signup})(SignupFormContainer);

If you attempt to sign up (make sure to use a different username than the one you used when testing this endpoint in Postman from the previous section of this tutorial) you should be able to see your user info, a token, and a success property in your console:

Signup response

Once again, it's bad to store and transmit plain text passwords. We're going to remedy this in part 3 of this series, so make sure to complete that section after this one!

Next let's store the token and user's information in localStorage. Back in your redux/auth.js file, update the console.log to store the response data in localStorage:

...
axios.post("/auth/signup", userInfo)
    .then(response => {
        const {token, user} = response.data
        localStorage.setItem("token", token)
        localStorage.setItem("user", JSON.stringify(user))
    })
...

If you sign up now, look under Application in your dev tools and click on Local Storage. you should see something like this:

The next step is to make a redux action creator containing our user info so that our redux global state has it. Later we will use it to populate our profile view and render certain components based on our login status.

Underneath your signup action creator in redux/auth.js, add a new authenticate action creator:

export function authenticate(user) {
    return {
        type: "AUTHENTICATE",
        user  // pass the user for storage in Redux store
    }
}

And dispatch authenticate in your signup action creator after the code that stores your token:

...
.then(response => {
    const {token, user} = response.data
    localStorage.setItem("token", token)
    localStorage.setItem("user", JSON.stringify(user))
    dispatch(authenticate(user))
})
...

We don't have anything telling the Redux store to keep information about the user, only the list of todos. We'll need to visit a couple places to add user to our Redux store:

1. redux/index.js
...
import todos from "./todos";
import user from "./auth";

const reducer = combineReducers({
    todos,
    user
});
...

That reducer hasn't been written yet, so let's head to...

2. redux/auth.js

Underneath your authenticate action creator:

const initialState = {
    username: "",
    isAdmin: false,
    isAuthenticated: false
}

export default function reducer(state = initialState, action) {
    switch (action.type) {
        case "AUTHENTICATE":
            return {
                ...state,
                ...action.user,
                isAuthenticated: true
            }
        default:
            return state;
    }
}

Now, when you signup, a new user is created in the database, and the token is stored in localStorage and the user's info is stored in both the Redux store and localStorage!


Logging In

There is no substantial difference between logging in and signing up, so here's a quick run-through.

Create login function in redux/auth.js.

export function login(credentials) {
    return dispatch => {
        axios.post("/auth/login", credentials)
            .then(response => {
                const {token, user} = response.data;
                localStorage.setItem("token", token)
                localStorage.setItem("user", JSON.stringify(user))
                dispatch(authenticate(user))
            })
            .catch((err) => {
                console.error(err);
            });
    }
}

Notice how we simply dispatch the authenticate action creator once we receive the token and user info.

Next, use connect to connect the login action creator to props in your LoginFormContainer and call it within handleSubmit. In main/Login/index.js:

...
import {connect} from "react-redux";
import {login} from "../../redux/auth";

...

handleSubmit(e) {
    e.preventDefault();
    this.props.login(this.state.inputs);
    this.clearInputs();
}

...

export default connect(null, {login})(LoginFormContainer);

Now you should be able to successfully sign in!

User info in console

Token and User info in local storage


Logging Out

Logging out is as simple as removing the authorization token and user info from local storage and resetting Redux's user property in state back to default.

// redux/auth.js
...
export function logout() {
    localStorage.removeItem("token")
    localStorage.removeItem("user")
    return {
        type: "LOGOUT"
    }
}

...

// (In the reducer's switch)
case "LOGOUT":
    return initialState;

It also makes sense to remove the list of todos from the Redux store. Over in redux/todos.js, add a case in the reducer that deals with the todos array to handle the "LOGOUT" action as well.

...
case "LOGOUT":
    return initialTodos;  // an empty array

Notice that every action will run through every reducer. You can have one action affect multiple parts of the Redux store simply by adding a case in any reducer's switch statement.

Since our logout button lives in our Navbar.js component we will have to link up Redux so that we can call logout from there.

import React from 'react';
import {Link} from "react-router-dom";
import {connect} from "react-redux";
import {logout} from "../redux/auth";

function Navbar(props) {
    return (
        <div className="navbar-wrapper">
            <div className="nav-link"><Link to="/">Sign Up</Link></div>
            <div className="nav-link"><Link to="/login">Log In</Link></div>
            <div className="nav-link"><Link to="/todos">Todos</Link></div>
            <div className="nav-link"><Link to="/profile">Profile</Link></div>
            <div className="nav-link">
                <button onClick={props.logout}>Logout</button>
            </div>
        </div>
    )
}

export default connect(null, {logout})(Navbar);

Now when you click the "Logout" button, the token and user info disappears from localStorage, and your user info no longer appears in state!


Next

Now that you can sign in, sign up, and logout, it is time to be able for a user to be add and see just their own todos. This means enabling the user to navigate to and from pages only which he/she is authorized.

Recap

So far we are able to:

  • Create a new account, login with an existing account, and logout.
  • Store our user info in state.
  • Store our authorization token in Local Storage so that even if we refresh the page, we don't lose it.

What are we missing?

  • We cannot view or add to our todo list.
  • All our nav links are visible despite our login status.
  • We are not redirected to a new page after we signup/login/logout.

Interceptors

We receive an authorization error when we attempt to view or add todos because there is no token present in our GET/POST request header when we send it.

If you recall from Part 1 of this series, we simply placed the token into the headers via Postman. It looked like this:
.

The node package Axios makes it very easy to attach information to request headers using what are called interceptors. They are just middleware functions that do something to the outgoing request (or incoming response) before it gets sent.

In our case we just want to retrieve the token from Local Storage and set it to the value of 'Authorization', a property of the config.headers object:
Add the following code to the top of the file.

// /redux/todos.js
import axios from "axios";

let todoAxios = axios.create();

todoAxios.interceptors.request.use((config)=>{
    const token = localStorage.getItem("token");
    config.headers.Authorization = `Bearer ${token}`;
    return config;
})

Then replace all instances of axios with todoAxios
Also make sure that your todoUrl is set to /api/todo/ and not just /todo/

The config object is just an object containing metadata about the request.

Now, try logging in and adding a todo! You should see something like this:

You can now continue to part 3 here