Token Auth with JWTs Part 3 - Security and UX - React + Redux

This is part 3 in a series of tutorials about authentication. If you haven't already, get some additional context by going through the other parts:

  1. Authentication Basics
  2. Token Auth with JWTs Part 1 - Server Setup
  3. Token Auth with JWTs Part 2 - React + Redux

SET UP

Assuming you are in the branch for part 2 you will need to checkout from there into part 3:

git add -A
git commit -m '<your message>'
git checkout part-3-react-auth-beginning
# `cd` into the "client" folder
npm i
# Just in case, `cd` into the "server" folder
npm i
# Run server with nodemon, and in separate terminal window run client with `npm start`

Hashing passwords

Our database is saving raw passwords. This is simply never a good idea. Fortunately it's fairly easy to include some proven encryption methods to save only "hashed" (or encrypted) passwords to the database, then have our server decrypt them when it checks for password equality on login.

Install bcrypt

bcrypt is a package that hashes passwords and checks hashed passwords.

npm install bcrypt

Add a "pre save" hook to userSchema

We're going to add code to our models/user.js file that hashes the given password right before saving it to the database. First we'll require bcrypt, then we'll add a "pre-save" hook to the schema. The entire file should look like this:

...
const bcrypt = require("bcrypt");

const userSchema = new Schema({
    ...
});

// It's critical that you don't convert this to an arrow function
// Otherwise it won't use the correct instance of `this`
userSchema.pre("save", function (next) {
    const user = this;
    if (!user.isModified("password")) return next();
    bcrypt.hash(user.password, 10, (err, hash) => {
        if (err) return next(err);
        user.password = hash;
        next();
    });
});

module.exports = mongoose.model("User", userSchema);

Before doing this step, your user in the database looked something like this:

{
    "_id": ObjectId('567080e1bedf163525bf9fb2'),
    "username": "bob",
    "password": "bob",
    "admin": false,
    "__v": 0
}

Now when you create a new user from your ReactJS client or Postman, your user should look like this:

{
    ...
    "password": "$2a$10$KvC5C0tPk7xW4ltrmS0orOC24rrbFprke7Cq0vdRfOEnIsxJxTdPK",
    ...
}

Add a password checking method to your user schema

If you tried to log in with a user whose password was hashed (any user you created since finishing the last step) right now, you would never be able to authenticate with your original password. As it stands, our login route is checking if what the user entered in the password form field is equal to the password stored in the database. But we just hashed the password, so instead we need to let bcrypt compare the entered login password with the one stored in the database so it can accurately assess if it's a match.

We can add our own methods to the user schema so that any user document created is able to run that method. Add the following below the pre save hook we just wrote:

// Again, do not change these function declarations to arrow functions!
userSchema.methods.checkPassword = function(passwordAttempt, callback) {
    bcrypt.compare(passwordAttempt, this.password, (err, isMatch) => {
        if (err) return callback(err);
        callback(null, isMatch);
    });
};

Use our new password checking method inside routes/auth.js

Now instead of checking the incoming password (plain text) against the hashed password in the database, we can delegate the checking to bcrypt, which can decode the stored password and see if the incoming password matches correctly.

authRouter.post("/login", (req, res) => {
    User.findOne({ username: req.body.username.toLowerCase() }, (err, user) => {
        if (err) return res.status(500).send(err);
        if (!user) {
            return res.status(403).send({success: false, err: "Username or password are incorrect"})
        }
        user.checkPassword(req.body.password, (err, match) => {
            if (err) return res.status(500).send(err);
            if (!match) res.status(401).send({ success: false, message: "Username or password are incorrect" });
            const token = jwt.sign(user.withoutPassword(), process.env.SECRET);
            return res.send({ token: token, user: user.withoutPassword(), success: true })
        });
    });
})

It simply uses the bcrypt.compare() method to check if what was entered by the user is equal to the original password before it was hashed. If it is, match is true, and we go ahead and create the token to send to the user.

Deleting the user's password before sending to the client

Even though we're hashing the password before saving it to the database, this is really only most helpful in the case of a database breach. The passwords gained by the hacker will be hashed and become very difficult and time-consuming to decrypt. However, if you ever need to send the user their own information, like if you want to include a "Profile" page where they can edit their info, we'll be even safer if we don't even send the hashed password as part of the user's profile at all.

We can add another method to our userSchema that returns a modified version of the user object without a password included. (You can add other properties you don't want being sent here as well.)

In models/user.js just above the final module.exports line, add the following:

// Don't change to an arrow function
userSchema.methods.withoutPassword = function () {
    const user = this.toObject();
    delete user.password;
    return user;
};

Back in the authRouter.js file, we need to use this new .withoutPassword() method whenever we're sending the user object in the response.

In all places it says user.toObject(), replace it with user.withoutPassword(). There should be 4 of them to change.

Now when we log in, this is the data that is received by the client:

password removed

No password included!


There are a lot of important security measures you can include in your application, and we've only touched on the very surface of all the precautions you can take. Data security is an entire career of its own, so we won't be diving into it here, but if you're interested, there are lots of free resources out there to read up on and use for practice.

For now, let's turn the improving the user experience of the frontend app.

User experience

So far, a user technically can securely signup, login, and add todos that are only available to them. However, their experience on our app is a bit clunky.

Profile

Right now, the profile page looks blank. Let's give it some info to display.

// main/Profile.js
import React from "react";
import {connect} from "react-redux";

function Profile(props) {
    return (
        <div>
            <h2>Welcome, <i>@{props.username}</i></h2>
        </div>
    )
}

export default connect(state => state.auth, {})(Profile);

Then, make sure to import and render ProfileComponent from within the Switch component in App.js:

...
import Profile from "./Profile";
...

<Switch>
    <Route exact path="/" component={Signup} />
    <Route path="/login" component={Login} />
    <Route path="/todos" component={TodoList} />
    <Route path="/profile" component={Profile} />
</Switch>

...

Handling Errors

This tutorial doesn't go into form validation, but we can at least inform the user if their credentials are invalid.

In redux/auth.js we'll create a helper action creator that returns an action containing a key and the error code returned by the server.

function authError(key, errCode) {
    return {
        type: "AUTH_ERROR",
        key,
        errCode
    }
}

Within the .catch callback of the signup and login action creator, dispatch authError:

.catch((err) => {
                console.error(err);
                dispatch(authError("signup", err.response.status));
               // use "login" in the login function
            })

Next lets change our initial state to this:

let initialState = {
    todos: [],
    user: {
        username: "",
        admin: false,
        _id: ""
    },
    authErrCode: {
        signup: "",
        login: ""
    },
    isAuthenticated: false
}

Now we can handle the action in our reducer, which will simply set the error code and key into our state.

switch (action.type) {
        case "AUTH_ERROR":
            return {
                ...state,
                authErrCode: {
                    ...state.authErrCode,
                    [action.key]: action.errCode
                }
            }

To display an appropriate message on the form in case of error, start by linking up state to both the Signup and Login components.

export default connect(state => state.auth, { signup })(SignupContainer);
export default connect(state => state.auth, { login })(LoginContainer);

Then in the render method we'll create the proper message based on what the error code is.

render() {
        let authErrCode = this.props.authErrCode.signup;
{/* use authErrCode.login in login component */}
        let errMsg = "";
        if (authErrCode < 500 && authErrCode > 399) {
            errMsg = "Invalid username or password!";
        } else if (authErrCode > 499) {
            errMsg = "Server error!";
        }
        return (
            <SignupForm
                handleChange={this.handleChange.bind(this)}
                handleSubmit={this.handleSubmit.bind(this)}
                errMsg={errMsg}
                {...this.state.inputs} />
        )
    }

Finally, just add a <p> tag in the SignupForm and LoginForm components containing the message from props.

//...
<button type="submit">Create Account</button>
<p>{props.errMsg}</p>

If you try to signup with a name that already exists, this is what displays:

Since we want the error message to go away when we sign up or log in successfully we can easily reset the authErrCode object in theauthenticate case of the reducer:

case "AUTHENTICATE":
            return {
                ...state,
                user: action.user,
                isAuthenticated: true,
                authErrCode: initialState.authErrCode
            }

Conditional Views

In our redux state we have a property called isAuthenticated. Its value gets toggled depending on whether we have logged in or out. We will be rendering the links in our navbar accordingly.

First, connect state to props in Navbar.js

export default connect(state => state.auth, { logout })(Navbar);

Since we only want the signup and signin links to show if we AREN'T logged in we can write this:

function Navbar(props){
        const { isAuthenticated } = props;
        return (
            <div className="navbar-wrapper">
                {!isAuthenticated && <div className="nav-link"><Link to="/">Sign Up</Link></div>}
                {!isAuthenticated && <div className="nav-link"><Link to="/login">Log In</Link></div>}

// ... 
        )
    }
}

The AND operator lets us return <div> ... </div> based on the value of isAuthenticated.

Similarly, we want to see ONLY the logout, profile, and todos links if we ARE Logged in:

...
            <div className="navbar-wrapper">
                ...
                {isAuthenticated && <div className="nav-link"><Link to="/todos">Todos</Link></div> }
                {isAuthenticated && <div className="nav-link"><Link to="/profile">Profile</Link></div>}
                {isAuthenticated && <div className="nav-link"><button onClick={props.logout}>Logout</button></div>}
            </div>
        )
    }
}

Try logging in and logging out, you should see the navbar change!

Signed out view

Signed in view

Protected Routes###

There is still an obvious problem. Once we sign in or logout, we remain on the same page. We need to tell our routes to render certain components based on the isAuthenticated property.

Auto-navigating from logout

We will be making a new file called ProtectedRoute.js inside our /main folder, which will replace some of our normal <Route /> components. We will connect redux state so that we have access to isAuthenticated. Also, import Route and Redirect from "react-router-dom".

/main/ProtectedRoute.js

import React, { Component } from 'react';
import { connect } from "react-redux";
import { Route, Redirect } from "react-router-dom";

class ProtectedRoute extends Component {
    render() {
        return (
            //logic to go here
        )
    }
}
export default connect(state => state.auth,{})(ProtectedRoute);

The idea here is simple. Render a route to a component if user is authorized, otherwise redirect them to the login page.

ProtectedRoute takes two additional props:

  • component - the component we want to protect.
  • path - url endpoint to check for.
class ProtectedRoute extends Component {
    render() {
        const {isAuthenticated, path} = this.props;
        const Component = this.props.component;
        return (
            isAuthenticated ?
                <Route path={path} component={Component} /> :
                <Redirect to="/" />
        )
    }
}

From App.js, import ProtectedRoute and replace the appropriate Route components with it (the ones rendering the TodoList and Profile):

import ProtectedRoute from "./ProtectedRoute";

function App(props){
    return (
        <div className="app-wrapper">
            <Navbar/>
            <Switch>
                <Route exact path="/" component={Signup}/>
                <Route path="/login" component={Login}/>
                <ProtectedRoute path="/todos" component={TodoList}/>
                <ProtectedRoute path="/profile" component={Profile}/>
            </Switch>
        </div>
    )
}

We're almost there! If you click logout while you are viewing the profile or todos page, you will instantly be transferred to the login page.

Auto-navigating out of login/signup

Now we must render the login/signup pages based on the isAuthenticated property. If we are logged in we will Redirect the user to the endpoint "/profile". Otherwise we will render it like normal.

All you need to do is import Redirect and withRouter from "react-router-dom" into App.js and connect to redux.

import { Route, Switch, withRouter, Redirect } from "react-router-dom";
// ...
import {connect} from "react-redux";

function App(props){
// ...
}

export default withRouter(connect(state => state.auth,{})(App));

withRouter() is necessary to let App know about changes in the pathname.

Right now we are directly rendering LoginFormContainer and SignupFormContainer from within the Switch component. We need to make a slight adjustment so that each Route checks whether the user is authenticated before rendering the component.

<Route /> components can take an additional
prop called render. We are going to set it to a callback function that will do the above check.

// App.js
// ...
function App(props) {
    const {isAuthenticated} = props;
    return (
        <div className="app-wrapper">
            <Navbar/>
            <Switch>
                    <Route exact path="/" render={ props => isAuthenticated ? 
                        <Redirect to="/profile"/> :
                        <Signup {...props}/>
                    }/>
                    <Route path="/login" render={ props => isAuthenticated ?
                        <Redirect to="/profile"/> :
                        <Login {...props}/>
                    } />
                    <ProtectedRoute path="/todos" component={TodoList}/>
                    <ProtectedRoute path="/profile" component={Profile}/>
            </Switch>
        </div>
    )
}

That's it! You should now see your profile page when you login or sign up.

Refresh

Try logging in and then refreshing the page while you are in a protected route. What happens? It sends you back to your login page! That's kind of annoying. Luckily, it's an easy fix.

In /redux/auth.js We're going to write a function that sends a GET request to the "/api/profile/" endpoint, which will send us our user info and login status. However, we will need to intercept this request with the the token stored in localStorage because our server requires authentication on all "/api" routes.

/redux/auth.js

import axios from "axios";

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

...
// create and export new action creator
export function verify() {
    return dispatch => {
        profileAxios.get("/api/profile")
            .then(response => {
                let { user } = response.data;
                dispatch(authenticate(user));
            })
            //we will handle errors in a moment
    }
}

Basically we are just proving we are logged in, without having to explicitly prove it every time using a form. It's like showing your wristband at Disneyland to ride the rides.

Now all we have to do is call it when our App component mounts.

Import verify into App.js:

import {verify} from "../redux/actions/index";

convert your App component into a class and connect verify to props:

class App extends Component {
 render(){
   const { isAuthenticated } = this.props;
   return ( ... )
 }
}

export default withRouter(connect(state => state.auth,{verify})(App));

Call it from componentDidMount()

componentDidMount(){
        this.props.verify();
    }

Try refreshing while you are logged in. You will still be authorized! Unfortunately, there is a peculiar bug... It will always redirect you to the profile page even if you were on the todo page.

Why is this happening?

There is a brief lag time between our component mounting and our verify function receiving user data. During this time our redux store thinks we are not authenticated. Therefor, if we refresh, we are redirected to the login section for a split second before being sent back to the profile page, which is dealt with by our <Route /> components.

What if there was some way to tell our App to wait for the server response before doing any routing? Luckily, there is!

in /redux/auth.js, add a new property to intialState:

const initialState = {
  ...
  loading: true
}

Now, edit all your auth reducer cases to set loading to false:

case "AUTHENTICATE":
            return {
                ...state,
                ...action.user,
                isAuthenticated: true,
                authErrCode: initialState.authErrCode,
                loading: false
            }
        case "LOGOUT":
            return {
                ...initialState,
                loading: false
            };
        case "AUTH_ERROR":
            return {
                ...state,
                authErrCode: {
                    ...state.authErrCode,
                    [action.key]: action.errCode
                },
                loading: false
            }

Now we just need to handle the potential for an error from the verify function. If for some reason we no longer have a token in localStorage, then our server won't let us in. But we don't want to just render a loading screen forever!

verify(){
 return dispatch =>{
    ...
    .catch(err => {
                dispatch(authError("verify", err.response.status));
            });
  }
}

Finally, in App.js we simply tell the render function to only render the <Route /> components when loading is false:

...
render() {
        const { isAuthenticated, loading } = this.props;
        return (
            <div className="app-wrapper">
                <Navbar />
                {loading ?
                    <div>...Loading user data </div>
                    :
                    <Switch>
                        <Route exact path="/" render={props => isAuthenticated ?
                            <Redirect to="/profile" /> :
                            <Signup {...props} />} />
                        <Route path="/login" render={props => isAuthenticated ?
                            <Redirect to="/profile" /> :
                            <Login {...props} />} />
                        <ProtectedRoute path="/todos" component={TodoList} />
                        <ProtectedRoute path="/profile" component={Profile} />
                    </Switch>
                }
            </div>
        )
    }

Conclusion

Congratulations! You made it! You have the necessary tools to make a basic token authentication system in React. From here you can try to add on more security features such as email verification, password reset, and profile edits!