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:
- Authentication Basics
- Token Auth with JWTs Part 1 - Server Setup
- 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:
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!