In this multi-part project, you will learn how to put together an entire Express + React application with authentication.
First, you need to setup the backend of your application. This includes installing dependencies, setting up Sequelize, initializing your Express application, connecting Express security middlewares, and testing your server setup.
In this project, you will separate the backend Express code from the frontend React code.
Create a folder called authenticate-me
. Inside that folder, create two more
folders called backend
and frontend
.
Your file structure should look like this:
authenticate-me
├── backend
└── frontend
Create a .gitignore
file at the root of the project with the following
contents:
node_modules
.env
build
.DS_Store
In the backend
folder, initialize the server's package.json
by running
npm init -y
.
npm install
the following packages as dependencies:
bcryptjs
- password hashingcookie-parser
- parsing cookies from requestscors
- CORScsurf
- CSRF protectiondotenv
- load environment variables into Node.js from a.env
fileexpress
- Expressexpress-async-handler
- handlingasync
route handlersexpress-validator
- validation of request bodieshelmet
- security middlewarejsonwebtoken
- JWTmorgan
- logging information about server requests/responsesper-env
- use environment variables for starting app differentlypg@">=8.4.1"
- PostgresQL greater or equal to version 8.4.1sequelize@5
- Sequelizesequelize-cli@5
- usesequelize
in the command line
npm install -D
the following packages as dev-dependencies:
dotenv-cli
- usedotenv
in the command linenodemon
- hot reload serverbackend
files
In the backend
folder, create a .env
file that will be used to define your
environment variables.
Populate the .env
file based on the example below:
PORT=5000
DB_USERNAME=auth_app
DB_PASSWORD=«auth_app user password»
DB_DATABASE=auth_db
DB_HOST=localhost
JWT_SECRET=«generate_strong_secret_here»
JWT_EXPIRES_IN=604800
Assign PORT
to 5000
, add a user password and a strong JWT secret.
Recommendation to generate a strong secret: create a random string using
openssl
(a library that should already be installed in your Ubuntu/MacOS shell). Runopenssl rand -base64 10
to generate a random JWT secret.
Next, you will create a js
configuration file that will read the environment
variables loaded and export them.
Add a folder called config
in your backend
folder. Inside of the folder,
create an index.js
file with the following contents:
// backend/config/index.js
module.exports = {
environment: process.env.NODE_ENV || 'development',
port: process.env.PORT || 5000,
db: {
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: process.env.DB_HOST
},
jwtConfig: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN
}
};
Each environment variable will be read and exported as a key from this file.
You will set up Sequelize to look in the backend/config/database.js
file for
its database configurations. You will also set up the backend/db
folder to
contain all the files for models, seeders, and migrations.
To do this, create a .sequelizerc
file in the backend
folder with the
following contents:
// backend/.sequelizerc
const path = require('path');
module.exports = {
config: path.resolve('config', 'database.js'),
'models-path': path.resolve('db', 'models'),
'seeders-path': path.resolve('db', 'seeders'),
'migrations-path': path.resolve('db', 'migrations')
};
Initialize Sequelize to the db
folder by running:
npx sequelize init
Replace the contents of the newly created backend/config/database.js
file with
the following:
// backend/config/database.js
const config = require('./index');
const db = config.db;
const username = db.username;
const password = db.password;
const database = db.database;
const host = db.host;
module.exports = {
development: {
username,
password,
database,
host,
dialect: 'postgres',
seederStorage: 'sequelize'
},
production: {
use_env_variable: 'DATABASE_URL',
dialect: 'postgres',
seederStorage: 'sequelize',
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false
}
}
}
};
This will allow you to load the database configuration environment variables
from the .env
file into the config/index.js
.
Notice how the production
database configuration has different keys than the
development
configuration? When you deploy your application to production,
your database will be read from a URL path instead of a username, password, and
database name combination.
Next, create a user using the same credentials in the .env
file with the
ability to create databases.
psql -c "CREATE USER <username> PASSWORD '<password>' CREATEDB"
Finally, create the database using sequelize-cli
.
npx dotenv sequelize db:create
Remember, any sequelize db:
commands need to be prefixed with dotenv
to load
the database configuration environment variables from the .env
file.
After you setup Sequelize, it's time to start working on getting your Express application set up.
Create a file called app.js
in the backend
folder. Here you will initialize
your Express application.
At the top of the file, import the following packages:
const express = require('express');
const morgan = require('morgan');
const cors = require('cors');
const csurf = require('csurf');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');
Create a variable called isProduction
that will be true
if the environment
is in production or not by checking the environment
key in the configuration
file (backend/config/index.js
):
const { environment } = require('./config');
const isProduction = environment === 'production';
Initialize the Express application:
const app = express();
Connect the morgan
middleware for logging information about requests and
responses:
app.use(morgan('dev'));
Add the cookie-parser
middleware for parsing cookies and the express.json
middleware for parsing JSON bodies of requests with Content-Type
of
"application/json"
.
app.use(cookieParser());
app.use(express.json());
Add several security middlewares. First, only allow CORS (Cross-Origin Resource
Sharing) in development using the cors
middleware because the React frontend
will be served from a different server than the Express server. CORS isn't
needed in production since all of our React and Express resources will come from
the same origin. Second, enable better overall security with the helmet
middleware (for more on what helmet
is doing, see helmet on the npm
registry). React is generally safe at mitigating Cross-Site Scripting (XSS)
attacks, but do be sure to research how to protect your users from such attacks
in React when deploying a large production application. Now add the
crossOriginResourcePolicy
to the helmet
middleware with a policy
of
cross-origin
. This will allow images with URLs to render in deployment. Third,
add the csurf
middleware and configure it to use cookies.
// Security Middleware
if (!isProduction) {
// enable cors only in development
app.use(cors());
}
// helmet helps set a variety of headers to better secure your app
app.use(
helmet.crossOriginResourcePolicy({
policy: "cross-origin"
})
);
// Set the _csrf token and create req.csrfToken method
app.use(
csurf({
cookie: {
secure: isProduction,
sameSite: isProduction && "Lax",
httpOnly: true
}
})
);
The csurf
middleware will add a _csrf
cookie that is HTTP-only (can't be
read by JavaScript) to any server response. It also adds a method on all
requests (req.csrfToken
) that will be set to another cookie (XSRF-TOKEN
)
later on. These two cookies work together to provide CSRF (Cross-Site Request
Forgery) protection for your application. The XSRF-TOKEN
cookie value needs to
be sent in the header of any request with all HTTP verbs besides GET
. This
header will be used to validate the _csrf
cookie to confirm that the
request comes from your site and not an unauthorized site.
Now that you set up all the pre-request middleware, it's time to set up the routes for your Express application.
Create a folder called routes
in your backend
folder. All your routes will
live in this folder.
Create an index.js
file in the routes
folder. In this file, create an
Express router, create a test route, and export the router at the bottom of the
file.
// backend/routes/index.js
const express = require('express');
const router = express.Router();
router.get('/hello/world', function(req, res) {
res.cookie('XSRF-TOKEN', req.csrfToken());
res.send('Hello World!');
});
module.exports = router;
In this test route, you are setting a cookie on the response with the name of
XSRF-TOKEN
to the value of the req.csrfToken
method's return. Then, you are
sending the text, Hello World!
as the response's body.
Add the routes to the Express application by importing with the other imports
in backend/app.js
and connecting the exported router to app
after all the
middlewares.
// backend/app.js
const routes = require('./routes');
// ...
app.use(routes); // Connect all the routes
Finally, at the bottom of the app.js
file, export app
.
// backend/app.js
// ...
module.exports = app;
After setting up the Express application, it's time to create the server.
Create a folder in backend
called bin
. Inside of it, add a file called
www
with the following contents:
#!/usr/bin/env node
// backend/bin/www
const { port } = require('../config');
const app = require('../app');
const db = require('../db/models');
// Check the database connection before starting the app
db.sequelize
.authenticate()
.then(() => {
console.log('Database connection success! Sequelize is ready to use...');
// Start listening for connections
app.listen(port, () => console.log(`Listening on port ${port}...`));
})
.catch((err) => {
console.log('Database connection failure.');
console.error(err);
});
Here, you will be starting your Express application to listen for server requests only after authenticating your database connection.
At this point, your database, Express application, and server are all set up and ready to be tested!
In your package.json
, add the following scripts:
"scripts": {
"sequelize": "sequelize",
"sequelize-cli": "sequelize-cli",
"start": "per-env",
"start:development": "nodemon -r dotenv/config ./bin/www",
"start:production": "node ./bin/www"
}
npm start
will run the /bin/www
in nodemon
when started in the development
environment with the environment variables in the .env
file loaded, or in
node
when started in production.
Now, it's time to finally test your entire set up!
Run npm start
in the backend
folder to start your server on the port defined
in the .env
file, which should be 5000
.
Navigate to the test route at http://localhost:5000/hello/world. You should
see the text Hello World!
. Take a look at your cookies in the Application
tab of your Chrome DevTools Inspector. Delete all the cookies to make sure there
are no lingering cookies from other projects, then refresh the page. You should
still see the text Hello World!
on the page as well as two cookies, one called
_csrf
and the other called XSRF-TOKEN
in your DevTools.
If you don't see this, then check your backend server logs in the terminal
where you ran npm start
. Then check your routes.
If there is a database connection error, make sure you set up the correct
username and password defined in the .env
file.
When you're finished testing, commit! Now is a good time to commit because you have working code.
The main purpose of this Express application is to be a REST API server. All the
API routes will be served at URL's starting with /api/
.
Get started by nesting an api
folder in your routes
folder. Add an
index.js
file in the api
folder with the following contents:
// backend/routes/api/index.js
const router = require('express').Router();
module.exports = router;
Import this file into the routes/index.js
file and connect it to the router
there.
// backend/routes/index.js
// ...
const apiRouter = require('./api');
router.use('/api', apiRouter);
// ...
All the URLs of the routes in the api
router will be prefixed with /api
.
Make sure to test this setup by creating the following test route in the
api
router:
// backend/routes/api/index.js
// ...
router.post('/test', function(req, res) {
res.json({ requestBody: req.body });
});
// ...
A router is created and an API test route is added to the router. The API test
route is accepting requests with the URL path of /api/test
with the HTTP verb
of POST
. It sends a JSON response containing whatever is in the body of the
request.
Test this route by navigating to the other test route,
http://localhost:5000/hello/world, and creating a fetch
request in the
browser's DevTools console. Make a request to /api/test
with the
POST
method, a body of { hello: 'world' }
, a "Content-Type"
header, and an
XSRF-TOKEN
header with the value of the XSRF-TOKEN
cookie located in your
DevTools.
Example fetch request:
fetch('/api/test', {
method: "POST",
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({ hello: 'world' })
}).then(res => res.json()).then(data => console.log(data));
Replace the <value of XSRF-TOKEN cookie>
with the value of the XSRF-TOKEN
cookie. If you don't have the XSRF-TOKEN
cookie anymore, access the
http://localhost:5000/hello/world route to add the cookie back.
After the response returns to the browser, parse the JSON response body and print it out.
If you get an error, check your backend server logs in the terminal where you
ran npm start
. Also, check your fetch
request syntax and your API router
setup.
After you finish testing, commit your code!
The next step is to set up your server error handlers.
Connect the following error handling middlewares after your route connections in
app.js
(i.e., after app.use(routes)
). Here is a refresher on how to create
an Express error-handling middleware.
The first error handler is actually just a regular middleware. It will catch
any requests that don't match any of the routes defined and create a server
error with a status code of 404
.
// backend/app.js
// ...
// Catch unhandled requests and forward to error handler.
app.use((_req, _res, next) => {
const err = new Error("The requested resource couldn't be found.");
err.title = "Resource Not Found";
err.errors = ["The requested resource couldn't be found."];
err.status = 404;
next(err);
});
If this resource-not-found middleware is called, an error will be created with
the message "The requested resource couldn't be found."
and a status code of
404
. Afterwards, next
will be invoked with the error. Remember, next
invoked with nothing means that error handlers defined after this middleware
will not be invoked. However, next
invoked with an error means that error
handlers defined after this middleware will be invoked.
The second error handler is for catching Sequelize errors and formatting them before sending the error response.
// backend/app.js
// ...
const { ValidationError } = require('sequelize');
// ...
// Process sequelize errors
app.use((err, _req, _res, next) => {
// check if error is a Sequelize error:
if (err instanceof ValidationError) {
err.errors = err.errors.map((e) => e.message);
err.title = 'Validation error';
}
next(err);
});
If the error that caused this error-handler to be called is an instance of
ValidationError
from the sequelize
package, then the error was created from
a Sequelize database validation error and the additional keys of title
string
and errors
array will be added to the error and passed into the next error
handling middleware.
The last error handler is for formatting all the errors before returning a JSON response. It will include the error message, the errors array, and the error stack trace (if the environment is in development) with the status code of the error message.
// backend/app.js
// ...
// Error formatter
app.use((err, _req, res, _next) => {
res.status(err.status || 500);
console.error(err);
res.json({
title: err.title || 'Server Error',
message: err.message,
errors: err.errors,
stack: isProduction ? null : err.stack
});
});
This should be the last middleware in the app.js
file of your Express
application.
You can't really test the Sequelize error handler now because you have no Sequelize models to test it with, but you can test the Resource Not Found error handler and the Error Formatter error handler.
To do this, try to access a route that hasn't been defined in your routes
folder yet, like http://localhost:5000/not-found.
If you see the json below, you have successfully set up your Resource Not Found and Error Formatter error handlers!
{
"title": "Resource Not Found",
"message": "The requested resource couldn't be found.",
"errors": [
"The requested resource couldn't be found."
],
"stack": "Error: The requested resource couldn't be found.\n ...<stack trace>..."
}
If you don't see the json above, check your backend server logs in your
terminal where you ran npm start
.
Make sure your other test route at http://localhost:5000/hello/world is still
working. If it is not working, make sure you are defining your error handlers
after your route connections in app.js
(i.e., after app.use(routes)
).
You will test the Sequelize error handler later when you populate the database with a table.
Before moving onto the next task, commit your error handling code!
Now that you have finished setting up both Sequelize and the Express application, you are ready to start implementing user authentication in the backend.
With Sequelize, you will create a Users
table that will have the following
schema:
column name | data type | constraints |
---|---|---|
id |
integer | not null, primary key |
username |
string | not null, indexed, unique, max 30 characters |
email |
string | not null, indexed, unique, max 256 characters |
hashedPassword |
binary string | not null |
createdAt |
datetime | not null, default value of now() |
updatedAt |
datetime | not null, default value of now() |
First, generate a migration and model file. Navigate into the backend
folder
in the terminal and run the following command:
npx sequelize model:generate --name User --attributes username:string,email:string,hashedPassword:string
This will create a file in your backend/db/migrations
folder and a file called
user.js
in your backend/db/models
folder.
In the migration file, apply the constraints in the schema. If completed correctly, your migration file should look something like this:
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('Users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
username: {
type: Sequelize.STRING(30),
allowNull: false,
unique: true
},
email: {
type: Sequelize.STRING(256),
allowNull: false,
unique: true
},
hashedPassword: {
type: Sequelize.STRING.BINARY,
allowNull: false
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.fn('now')
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.fn('now')
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('Users');
}
};
Migrate the Users
table by running the following command:
npx dotenv sequelize db:migrate
If there is an error when migrating, check your migration file and make changes.
If there is no error when migrating, but you want to change the migration file afterwards, undo the migration first, change the file, then migrate again.
Command to undo the migration:
npx dotenv sequelize db:migrate:undo
You can check out the Users
table schema created in your PostgreSQL database
by running the following command in the terminal:
psql <database name> -c '\d "Users"'
After you migrate the Users
table with the database-level constraints, you
need to add Sequelize model-level constraints. In your User
model file,
backend/db/models/user.js
, add the following constraints:
column name | data type | constraints |
---|---|---|
username |
string | not null, unique, min 4 characters, max 30 characters, isNotEmail |
email |
string | not null, unique, min 3 characters, max 256 characters, isEmail |
hashedPassword |
binary string | not null, min and max 60 characters |
See the Sequelize docs on model-level validations for a reminder on how to
apply these constraints. A custom validator needs to be created for the
isNotEmail
constraint. See here for a refresher on custom Sequelize
validators. You can use the imported isEmail
validation from the sequelize
package's Validator
to check if the username
is an email. If it is, throw an error with a message.
Your user.js
file should look like this with the applied constraints:
'use strict';
const { Validator } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
const User = sequelize.define('User', {
username: {
type: DataTypes.STRING,
allowNull: false,
validate: {
len: [4, 30],
isNotEmail(value) {
if (Validator.isEmail(value)) {
throw new Error('Cannot be an email.');
}
}
}
},
email: {
type: DataTypes.STRING,
allowNull: false,
validate: {
len: [3, 256]
}
},
hashedPassword: {
type: DataTypes.STRING.BINARY,
allowNull: false,
validate: {
len: [60, 60]
}
}
}, {});
User.associate = function(models) {
// associations can be defined here
};
return User;
};
Generate a user seeder file for the demo user with the following command:
npx sequelize seed:generate --name demo-user
In the seeder file, create a demo user with email
, username
, and
hashedPassword
fields. For the down
function, delete the user with the
username
or email
of the demo user. If you'd like, you can also add other
users and populate the fields with random fake data. To generate the
hashedPassword
you should use the bcryptjs
package's hashSync
method.
Your seeder file should look something like this:
'use strict';
const bcrypt = require('bcryptjs');
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('Users', [
{
email: '[email protected]',
username: 'Demo-lition',
hashedPassword: bcrypt.hashSync('password')
},
{
email: '[email protected]',
username: 'FakeUser1',
hashedPassword: bcrypt.hashSync('password2')
},
{
email: '[email protected]',
username: 'FakeUser2',
hashedPassword: bcrypt.hashSync('password3')
}
], {});
},
down: (queryInterface, Sequelize) => {
const Op = Sequelize.Op;
return queryInterface.bulkDelete('Users', {
username: { [Op.in]: ['Demo-lition', 'FakeUser1', 'FakeUser2'] }
}, {});
}
};
Notice how you do not need to add the createdAt
and updatedAt
fields for the
users. This is a result of the default value that you defined in the Sequelize
migration file for those fields.
Make sure to import bcryptjs
at the top of the file.
After you finish creating your demo user seed file, migrate the seed file by running the following command:
npx dotenv sequelize db:seed:all
If there is an error with seeding, check your seed file and make changes.
If there is no error in seeding but you want to change the seed file, remember to undo the seed first, change the file, then seed again.
Command to undo the migration for the most recent seed file:
npx dotenv sequelize db:seed:undo
Command to undo the migrations for all the seed files:
npx dotenv sequelize db:seed:undo:all
Check your database to see if the users have been successfully created by running:
psql <database name> -c 'SELECT * FROM "Users"'
To ensure that a user's information like their hashedPassword
doesn't get
sent to the frontend, you should define User
model scopes. Check out the
official documentation on model scoping to look up how to define a model scope
to prevent certain fields from being sent in a query.
For the default query when searching for Users
, the hashedPassword
,
updatedAt
, and, depending on your application, email
and createdAt
fields
should not be returned. To do this, set a defaultScope
on the User
model to
exclude the desired fields from the default query. For example, when you run
User.findAll()
all fields besides hashedPassword
, updatedAt
, email
, and
createdAt
will be populated in the return of that query.
Next, define a User
model scope for currentUser
that will exclude only the
hashedPassword
field. Finally, define another scope for including all the
fields, which should only be used when checking the login credentials of a user.
These scopes need to be explicitly used when querying. For example,
User.scope('currentUser').findByPk(id)
will find a User
by the specified
id
and return only the User
fields that the currentUser
model scope
allows.
Your user.js
model file should now look like this:
'use strict';
const { Validator } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
const User = sequelize.define('User', {
username: {
type: DataTypes.STRING,
allowNull: false,
validate: {
len: [3, 30],
isNotEmail(value) {
if (Validator.isEmail(value)) {
throw new Error('Cannot be an email.');
}
}
}
},
email: {
type: DataTypes.STRING,
allowNull: false,
validate: {
len: [3, 256]
}
},
hashedPassword: {
type: DataTypes.STRING.BINARY,
allowNull: false,
validate: {
len: [60, 60]
}
}
},
{
defaultScope: {
attributes: {
exclude: ['hashedPassword', 'email', 'createdAt', 'updatedAt']
}
},
scopes: {
currentUser: {
attributes: { exclude: ['hashedPassword'] }
},
loginUser: {
attributes: {}
}
}
});
User.associate = function(models) {
// associations can be defined here
};
return User;
};
These scopes help protect sensitive user information that should not be exposed to other users. You will be using these scopes in the later sections.
The backend login flow in this project will be based on the following plan:
- The API login route will be hit with a request body holding a valid credential (either username or email) and password combination.
- The API login handler will look for a
User
with the input credential in either theusername
oremail
columns. - Then the
hashedPassword
for that foundUser
will be compared with the inputpassword
for a match. - If there is a match, the API login route should send back a JWT in an
HTTP-only cookie and a response body. The JWT and the body will hold the
user's
id
,username
, andemail
.
The backend sign-up flow in this project will be based on the following plan:
- The API signup route will be hit with a request body holding a
username
,email
, andpassword
. - The API signup handler will create a
User
with theusername
, anemail
, and ahashedPassword
created from the inputpassword
. - If the creation is successful, the API signup route should send back a JWT in
an HTTP-only cookie and a response body. The JWT and the body will hold the
user's
id
,username
, andemail
.
The backend logout flow will be based on the following plan:
- The API logout route will be hit with a request.
- The API logout handler will remove the JWT cookie set by the login or signup API routes and return a JSON success message.
After creating the model scopes, you should create methods that the API routes
for authentication will use to interact with the Users
table. The planned
methods are based on the authentication flow plans outlined above.
Define an instance method User.prototype.toSafeObject
in the user.js
model
file. This method will return an object with only the User
instance
information that is safe to save to a JWT.
User.prototype.toSafeObject = function() { // remember, this cannot be an arrow function
const { id, username, email } = this; // context will be the User instance
return { id, username, email };
};
Define an instance method User.prototype.validatePassword
in the user.js
model file. It should accept a password
string and return true
if there is a
match with the User
instance's hashedPassword
. If there is no match, it
should return false
.
User.prototype.validatePassword = function (password) {
return bcrypt.compareSync(password, this.hashedPassword.toString());
};
You are using the bcryptjs
package to compare the password
and the
hashedPassword
, so make sure to import the package at the top of the user.js
file.
const bcrypt = require('bcryptjs');
Define a static method User.getCurrentUserById
in the user.js
model file
that accepts an id
. It should use the currentUser
scope to return a
User
with that id
.
User.getCurrentUserById = async function (id) {
return await User.scope('currentUser').findByPk(id);
};
Define a static method User.login
in the user.js
model file. It should
accept an object with credential
and password
keys. The method should search
for one User
with the specified credential
(either a username
or an
email
). If a user is found, then the method should validate the password
by
passing it into the instance's .validatePassword
method. If the password
is
valid, then the method should return the user by using the currentUser
scope.
User.login = async function ({ credential, password }) {
const { Op } = require('sequelize');
const user = await User.scope('loginUser').findOne({
where: {
[Op.or]: {
username: credential,
email: credential
}
}
});
if (user && user.validatePassword(password)) {
return await User.scope('currentUser').findByPk(user.id);
}
};
Define a static method User.signup
in the user.js
model file that accepts an
object with a username
, email
, and password
key. Hash the password
using
the bcryptjs
package's hashSync
method. Create a User
with the username
,
email
, and hashedPassword
. Return the created user using the currentUser
scope.
User.signup = async function ({ username, email, password }) {
const hashedPassword = bcrypt.hashSync(password);
const user = await User.create({
username,
email,
hashedPassword
});
return await User.scope('currentUser').findByPk(user.id);
};
There are three functions in this section that will aid you in authentication.
Create a folder called utils
in your backend
folder. Inside that folder, add
a file named auth.js
to store the auth helper functions.
At the top of the file, add the following imports:
// backend/utils/auth.js
const jwt = require('jsonwebtoken');
const { jwtConfig } = require('../config');
const { User } = require('../db/models');
const { secret, expiresIn } = jwtConfig;
This first function is setting the JWT cookie after a user is logged in or
signed up. It takes in the response and the session user and generates a JWT
using the imported secret. It is set to expire in however many seconds you
set on the JWT_EXPIRES_IN
key in the .env
file. The payload of the JWT will
be the return of the instance method .toSafeObject
that you added previously
to the User
model. After the JWT is created, it's set to an HTTP-only cookie
on the response as a token
cookie.
// backend/utils/auth.js
// ...
// Sends a JWT Cookie
const setTokenCookie = (res, user) => {
// Create the token.
const token = jwt.sign(
{ data: user.toSafeObject() },
secret,
{ expiresIn: parseInt(expiresIn) } // 604,800 seconds = 1 week
);
const isProduction = process.env.NODE_ENV === "production";
// Set the token cookie
res.cookie('token', token, {
maxAge: expiresIn * 1000, // maxAge in milliseconds
httpOnly: true,
secure: isProduction,
sameSite: isProduction && "Lax"
});
return token;
};
This function will be used in the login and signup routes later.
Certain authenticated routes will require the identity of the current session
user. You will create and utilize a middleware function called restoreUser that
will restore the session user based on the contents of the JWT cookie. Create a
middleware function that will verify and parse the JWT's payload and search the
database for a User
with the id in the payload. (This query should use the
currentUser
scope since the hashedPassword
is not needed for this
operation.) If there is a User
found, then save the user to a key of user
onto the request. If there is an error verifying the JWT or a User
cannot be
found with the id
, then clear the token
cookie from the response.
// backend/utils/auth.js
// ...
const restoreUser = (req, res, next) => {
// token parsed from cookies
const { token } = req.cookies;
return jwt.verify(token, secret, null, async (err, jwtPayload) => {
if (err) {
return next();
}
try {
const { id } = jwtPayload.data;
req.user = await User.scope('currentUser').findByPk(id);
} catch (e) {
res.clearCookie('token');
return next();
}
if (!req.user) res.clearCookie('token');
return next();
});
};
This will be added as a pre-middleware for route handlers and for the following authentication middleware.
The last authentication middleware to add is for requiring a session user to be authenticated before accessing a route.
Create an Express middleware called requireAuth
. Define this middleware as an
array with the restoreUser
middleware function you just created as the first
element in the array. This will ensure that if a valid JWT cookie exists, the
session user will be loaded into the req.user
attribute. The second middleware
will check req.user
and will go to the next middleware if there is a session
user present there. If there is no session user, then an error will be created
and passed along to the error-handling middlewares.
// backend/utils/auth.js
// ...
// If there is no current user, return an error
const requireAuth = [
restoreUser,
function (req, _res, next) {
if (req.user) return next();
const err = new Error('Unauthorized');
err.title = 'Unauthorized';
err.errors = ['Unauthorized'];
err.status = 401;
return next(err);
}
];
Both restoreUser and requireAuth will be applied as a pre-middleware to route handlers where needed.
Finally, export all the functions at the bottom of the file.
// backend/utils/auth.js
// ...
module.exports = { setTokenCookie, restoreUser, requireAuth };
Let's do some testing! It's always good to test your code anytime you have an opportunity to do it. Testing at the very end is not a good idea because it will be hard to pinpoint the location of the error in your code.
Add a test route in your backend/routes/api/index.js
file that will test the
setTokenCookie
function by getting the demo user and calling setTokenCookie
.
// backend/routes/api/index.js
// ...
// GET /api/set-token-cookie
const asyncHandler = require('express-async-handler');
const { setTokenCookie } = require('../../utils/auth.js');
const { User } = require('../../db/models');
router.get('/set-token-cookie', asyncHandler(async (_req, res) => {
const user = await User.findOne({
where: {
username: 'Demo-lition'
}
});
setTokenCookie(res, user);
return res.json({ user });
}));
// ...
Go to http://localhost:5000/api/set-token-cookie and see if there is a token
cookie set in your browser's DevTools. If there isn't, then check your backend
server logs in the terminal where you ran npm start
. Also, check the syntax
of your setTokenCookie
function as well as the test route.
Next, add a test route in your backend/routes/api/index.js
file that will test
the restoreUser
middleware by connecting the middleware and checking whether
or not the req.user
key has been populated by the middleware properly.
// backend/routes/api/index.js
// ...
// GET /api/restore-user
const { restoreUser } = require('../../utils/auth.js');
router.get(
'/restore-user',
restoreUser,
(req, res) => {
return res.json(req.user);
}
);
// ...
Go to http://localhost:5000/api/restore-user and see if the response has the
demo user information returned as JSON. Then, remove the token
cookie manually
in your browser's DevTools and refresh. The JSON response should be empty.
If this isn't the behavior, then check your backend server logs in the terminal
where you ran npm start
as well as the syntax of your restoreUser
middleware
and test route.
To set the token
cookie back, just go to the GET /api/set-token-cookie
route
again: http://localhost:5000/api/set-token-cookie.
Lastly, test your requireAuth
middleware by adding a test route in your
backend/routes/api/index.js
file. If there is no session user, the route will
return an error. Otherwise it will return the session user's information.
// backend/routes/api/index.js
// ...
// GET /api/require-auth
const { requireAuth } = require('../../utils/auth.js');
router.get(
'/require-auth',
requireAuth,
(req, res) => {
return res.json(req.user);
}
);
// ...
Set the token
cookie back by accessing the GET /api/set-token-cookie
route
again: http://localhost:5000/api/set-token-cookie.
Go to http://localhost:5000/api/require-auth and see if the response has the
demo user's information returned as JSON. Then, remove the token
cookie
manually in your browser's DevTools and refresh. The JSON response should now
be an "Unauthorized"
error.
If this isn't the behavior, then check your backend server logs in the terminal
where you ran npm start
as well as the syntax of your requireAuth
middleware
and test route.
To set the token
cookie back, just go to the GET /api/set-token-cookie
route
again: http://localhost:5000/api/set-token-cookie.
Once you are satisfied with the test results, you can remove all code for testing the user auth middleware routes.
It's finally time to create the authentication API routes!
In this section, you will add the following routes to your Express application:
- Login:
POST /api/session
- Logout:
DELETE /api/session
- Signup:
POST /api/users
- Get session user:
GET /api/session
First, create a file called session.js
in the backend/routes/api
folder.
This file will hold the resources for the route paths beginning with
/api/session
. Create and export an Express router from this file.
// backend/routes/api/session.js
const express = require('express')
const router = express.Router();
module.exports = router;
Next create a file called users.js
in the backend/routes/api
folder. This
file will hold the resources for the route paths beginning with /api/users
.
Create and export an Express router from this file.
// backend/routes/api/users.js
const express = require('express')
const router = express.Router();
module.exports = router;
Connect all the routes exported from these two files in the index.js
file
nested in the backend/routes/api
folder.
Your backend/routes/api/index.js
file should now look like this:
// backend/routes/api/index.js
const router = require('express').Router();
const sessionRouter = require('./session.js');
const usersRouter = require('./users.js');
router.use('/session', sessionRouter);
router.use('/users', usersRouter);
router.post('/test', (req, res) => {
res.json({ requestBody: req.body });
});
module.exports = router;
In the backend/routes/api/session.js
file, import the following code at the
top of the file and create an Express router:
// backend/routes/api/session.js
const express = require('express');
const asyncHandler = require('express-async-handler');
const { setTokenCookie, restoreUser } = require('../../utils/auth');
const { User } = require('../../db/models');
const router = express.Router();
The asyncHandler
function from express-async-handler
will wrap asynchronous
route handlers and custom middlewares.
Next, add the POST /api/session
route to the router using an asynchronous
route handler. In the route handler, call the login
static method from the
User
model. If there is a user returned from the login
static method, then
call setTokenCookie
and return a JSON response with the user information. If
there is no user returned from the login
static method, then create a "Login failed"
error and invoke the next error-handling middleware with it.
// backend/routes/api/session.js
// ...
// Log in
router.post(
'/',
asyncHandler(async (req, res, next) => {
const { credential, password } = req.body;
const user = await User.login({ credential, password });
if (!user) {
const err = new Error('Login failed');
err.status = 401;
err.title = 'Login failed';
err.errors = ['The provided credentials were invalid.'];
return next(err);
}
await setTokenCookie(res, user);
return res.json({
user
});
})
);
Make sure to export the router
at the bottom of the file.
// backend/routes/api/session.js
// ...
module.exports = router;
Test the login route by navigating to the http://localhost:5000/hello/world
test route and making a fetch request from the browser's DevTools console.
Remember, you need to pass in the value of the XSRF-TOKEN
cookie as a header
in the fetch request because the login route has a POST
HTTP verb.
If at any point you don't see the expected behavior while testing, then check
your backend server logs in the terminal where you ran npm start
. Also, check
the syntax in the session.js
as well as the login
method in the user.js
model file.
Try to login the demo user with the username first.
fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({ credential: 'Demo-lition', password: 'password' })
}).then(res => res.json()).then(data => console.log(data));
Remember to replace the <value of XSRF-TOKEN cookie>
with the value of the
XSRF-TOKEN
cookie found in your browser's DevTools. If you don't have the
XSRF-TOKEN
cookie anymore, access the http://localhost:5000/hello/world
route to add the cookie back.
Then try to login the demo user with the email next.
fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({ credential: '[email protected]', password: 'password' })
}).then(res => res.json()).then(data => console.log(data));
Now test an invalid user credential
and password
combination.
fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({ credential: 'Demo-lition', password: 'Hello World!' })
}).then(res => res.json()).then(data => console.log(data));
You should get a Login failed
error back with an invalid password
for the
user with that credential
.
Commit your code for the login route once you are done testing!
The DELETE /api/session
logout route will remove the token
cookie from the
response and return a JSON success message.
// backend/routes/api/session.js
// ...
// Log out
router.delete(
'/',
(_req, res) => {
res.clearCookie('token');
return res.json({ message: 'success' });
}
);
// ...
Notice how asyncHandler
wasn't used to wrap the route handler. This is because
the route handler is not async
.
Start by navigating to the http://localhost:5000/hello/world test route and
making a fetch request from the browser's DevTools console to test the logout
route. Check that you are logged in by confirming that a token
cookie is in
your list of cookies in the browser's DevTools. Remember, you need to pass in
the value of the XSRF-TOKEN
cookie as a header in the fetch request because
the logout route has a DELETE
HTTP verb.
Try to logout the session user.
fetch('/api/session', {
method: 'DELETE',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
}
}).then(res => res.json()).then(data => console.log(data));
You should see the token
cookie disappear from the list of cookies in your
browser's DevTools. If you don't have the XSRF-TOKEN
cookie anymore, access
the http://localhost:5000/hello/world route to add the cookie back.
If you don't see this expected behavior while testing, then check your backend
server logs in the terminal where you ran npm start
as well as the syntax in
the session.js
route file.
Commit your code for the logout route once you are done testing!
In the backend/routes/api/users.js
file, import the following code at the top
of the file and create an Express router:
const express = require('express');
const asyncHandler = require('express-async-handler');
const { setTokenCookie, requireAuth } = require('../../utils/auth');
const { User } = require('../../db/models');
const router = express.Router();
Next, add the POST /api/users
route to the router using the asyncHandler
function and an asynchronous route handler. In the route handler, call the
signup
static method on the User
model. If the user is successfully created,
then call setTokenCookie
and return a JSON response with the user information.
If the creation of the user is unsuccessful, then a Sequelize Validation error
will be passed onto the next error-handling middleware.
// backend/routes/api/users.js
// ...
// Sign up
router.post(
'/',
asyncHandler(async (req, res) => {
const { email, password, username } = req.body;
const user = await User.signup({ email, username, password });
await setTokenCookie(res, user);
return res.json({
user
});
})
);
Make sure to export the router
at the bottom of the file.
// backend/routes/api/users.js
// ...
module.exports = router;
Test the signup route by navigating to the http://localhost:5000/hello/world
test route and making a fetch request from the browser's DevTools console.
Remember, you need to pass in the value of the XSRF-TOKEN
cookie as a header
in the fetch request because the login route has a POST
HTTP verb.
If at any point you don't see the expected behavior while testing, check your
backend server logs in the terminal where you ran npm start
. Also, check the
syntax in the users.js
route file as well as the signup
method in the
user.js
model file.
Try to signup a new valid user.
fetch('/api/users', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({
email: '[email protected]',
username: 'Spidey',
password: 'password'
})
}).then(res => res.json()).then(data => console.log(data));
Remember to replace the <value of XSRF-TOKEN cookie>
with the value of the
XSRF-TOKEN
cookie found in your browser's DevTools. If you don't have the
XSRF-TOKEN
cookie anymore, access the http://localhost:5000/hello/world
route to add the cookie back.
Next, try to hit the Sequelize model validation errors by testing the following
which should give back a Validation error
:
email
is not unique (signup with an existingemail
)username
is not unique (signup with an existingusername
)
If you don't see the Validation error
for any of these, check the syntax in
your backend/db/models/user.js
model file.
Commit your code for the signup route once you are done testing!
The GET /api/session
get session user route will return the session user
as JSON under the key of user
. If there is not a session, it will return a
JSON with an empty object. To get the session user, connect the restoreUser
middleware.
Add the route to the router
in the backend/routes/api/session.js
file.
// backend/routes/api/session.js
// ...
// Restore session user
router.get(
'/',
restoreUser,
(req, res) => {
const { user } = req;
if (user) {
return res.json({
user: user.toSafeObject()
});
} else return res.json({});
}
);
// ...
Test the route by navigating to http://localhost:5000/api/session. You should
see the current session user information if you have the token
cookie. If you
don't have a token cookie, you should see an empty object returned.
If you don't have the XSRF-TOKEN
cookie anymore, access the
http://localhost:5000/hello/world route to add the cookie back.
If you don't see this expected behavior, then check your backend server logs in
your terminal where you ran npm start
and the syntax in the session.js
route
file and the restoreUser
middleware function.
Commit your code for the get session user route once you are done testing!
Before using the information in the body of the request, it's good practice to validate the information.
You will use a package, express-validator
, to validate the body of the
requests for routes that expect a request body. The express-validator
package
has two functions, check
and validationResult
that are used together to
validate the request body. check
is a middleware function creator that
checks a particular key on the request body. validationResult
gathers the
results of all the check
middlewares that were run to determine which parts of
the body are valid and invalid.
In the backend/utils
folder, add a file called validation.js
. In this file,
define an Express middleware called handleValidationErrors
that will call
validationResult
from the express-validator
package passing in the request.
If there are no validation errors returned from the validationResult
function,
invoke the next middleware. If there are validation errors, create an error
with all the validation error messages and invoke the next error-handling
middleware.
// backend/utils/validation.js
const { validationResult } = require('express-validator');
// middleware for formatting errors from express-validator middleware
// (to customize, see express-validator's documentation)
const handleValidationErrors = (req, _res, next) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
const errors = validationErrors
.array()
.map((error) => `${error.msg}`);
const err = Error('Bad request.');
err.errors = errors;
err.status = 400;
err.title = 'Bad request.';
next(err);
}
next();
};
module.exports = {
handleValidationErrors
};
The handleValidationErrors
function is exported at the bottom of the file. You
will test this function later when it's used.
Here's another great time to commit!
In the backend/routes/api/session.js
file, import the check
function from
express-validator
and the handleValidationError
function you just created.
// backend/routes/api/session.js
// ...
const { check } = require('express-validator');
const { handleValidationErrors } = require('../../utils/validation');
// ...
The check
function from express-validator
will be used with the
handleValidationErrors
to validate the body of a request.
The POST /api/session
login route will expect the body of the request to have
a key of credential
with either the username
or email
of a user and a key
of password
with the password of the user.
Make a middleware called validateLogin
that will check these keys and validate
them:
// backend/routes/api/session.js
// ...
const validateLogin = [
check('credential')
.exists({ checkFalsy: true })
.notEmpty()
.withMessage('Please provide a valid email or username.'),
check('password')
.exists({ checkFalsy: true })
.withMessage('Please provide a password.'),
handleValidationErrors
];
The validateLogin
middleware is composed of the check
and
handleValidationErrors
middleware. It checks to see whether or not
req.body.credential
and req.body.password
are empty. If one of them is
empty, then an error will be returned as the response.
Next, connect the POST /api/session
route to the validateLogin
middleware.
Your login route should now look like this:
// backend/routes/api/session.js
// ...
// Log in
router.post(
'/',
validateLogin,
asyncHandler(async (req, res, next) => {
const { credential, password } = req.body;
const user = await User.login({ credential, password });
if (!user) {
const err = new Error('Login failed');
err.status = 401;
err.title = 'Login failed';
err.errors = ['The provided credentials were invalid.'];
return next(err);
}
await setTokenCookie(res, user);
return res.json({
user
});
})
);
Test validateLogin
by navigating to the http://localhost:5000/hello/world
test route and making a fetch request from the browser's DevTools console.
Remember, you need to pass in the value of the XSRF-TOKEN
cookie as a header
in the fetch request because the login route has a POST
HTTP verb.
If at any point you don't see the expected behavior while testing, check your
backend server logs in the terminal where you ran npm start
. Also, check the
syntax in the users.js
route file as well as the handleValidationErrors
middleware.
Try setting the credential
user field to an empty string. You should get a
Bad Request
error back.
fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({ credential: '', password: 'password' })
}).then(res => res.json()).then(data => console.log(data));
Remember to replace the <value of XSRF-TOKEN cookie>
with the value of the
XSRF-TOKEN
cookie found in your browser's DevTools. If you don't have the
XSRF-TOKEN
cookie anymore, access the http://localhost:5000/hello/world
route to add the cookie back.
Test the password
field by setting it to an empty string. You should get a
Bad Request
error back with Please provide a password
as one of the errors.
fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({ credential: 'Demo-lition', password: '' })
}).then(res => res.json()).then(data => console.log(data));
Once you finish testing, commit your code!
In the backend/routes/api/users.js
file, import the check
function from
express-validator
and the handleValidationError
function you created.
// backend/routes/api/users.js
// ...
const { check } = require('express-validator');
const { handleValidationErrors } = require('../../utils/validation');
// ...
The POST /api/users
signup route will expect the body of the request to have
a key of username
, email
, and password
with the password of the user
being created.
Make a middleware called validateSignup
that will check these keys and
validate them:
// backend/routes/api/users.js
// ...
const validateSignup = [
check('email')
.exists({ checkFalsy: true })
.isEmail()
.withMessage('Please provide a valid email.'),
check('username')
.exists({ checkFalsy: true })
.isLength({ min: 4 })
.withMessage('Please provide a username with at least 4 characters.'),
check('username')
.not()
.isEmail()
.withMessage('Username cannot be an email.'),
check('password')
.exists({ checkFalsy: true })
.isLength({ min: 6 })
.withMessage('Password must be 6 characters or more.'),
handleValidationErrors
];
The validateSignup
middleware is composed of the check
and
handleValidationErrors
middleware. It checks to see if req.body.email
exists
and is an email, req.body.username
is a minimum length of 4 and is not an
email, and req.body.password
is not empty and has a minimum length of 6. If at
least one of the req.body
values fail the check, an error will be returned as
the response.
Next, connect the POST /api/users
route to the validateSignup
middleware.
Your signup route should now look like this:
// backend/routes/api/users.js
// ...
// Sign up
router.post(
'/',
validateSignup,
asyncHandler(async (req, res) => {
const { email, password, username } = req.body;
const user = await User.signup({ email, username, password });
await setTokenCookie(res, user);
return res.json({
user,
});
}),
);
Test validateSignup
by navigating to the http://localhost:5000/hello/world
test route and making a fetch request from the browser's DevTools console.
Remember, you need to pass in the value of the XSRF-TOKEN
cookie as a header
in the fetch request because the login route has a POST
HTTP verb.
If at any point you don't see the expected behavior while testing, check your
backend server logs in the terminal where you ran npm start
. Also, check the
syntax in the users.js
route file as well as the handleValidationErrors
middleware.
First, test the signup route with an empty password
field. You should get a
Bad Request
error back with 'Please provide a password'
as one of the
errors.
fetch('/api/users', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({
email: '[email protected]',
username: 'Firestar',
password: ''
})
}).then(res => res.json()).then(data => console.log(data));
Remember to replace the <value of XSRF-TOKEN cookie>
with the value of the
XSRF-TOKEN
cookie found in your browser's DevTools. If you don't have the
XSRF-TOKEN
cookie anymore, access the http://localhost:5000/hello/world
route to add the cookie back.
Then try to sign up with more invalid fields to test out the checks in the
validateSignup
middleware. Make sure to cover each of the following test
cases which should give back a Bad Request
error:
email
field is an empty stringemail
field is not an emailusername
field is an empty stringusername
field is only 3 characters longusername
field is an emailpassword
field is only 5 characters long
If you don't see the Bad Request
error for any of these, check your syntax for
the validateSignup
middleware.
Commit your code once you're done testing!
You can now remove the GET /hello/world
test routes. Do not remove the
POST /api/test
route just yet. You will be using it in the next part.
Awesome work! You just finished setting up the entire backend for this project! In the next part, you will implement the React frontend.