Now that we've got our database models, we need to setup some authentication routes to be able to query our database. I decided to use the popular PassportJS library to handle authentication.
The most important thing to understand about Passport is that it's incredibly modular. That means that handling user login, and handling JWT authentication requires two different libraries with separate Passport "strategies". Strategies are middleware functions that allow us to apply some verification to the incoming request.
Username/Password Login vs Federated
I've followed a number of other posts which are using things like Firebase or other federated sign-in authentication tools. My reasoning for using local username/password sign in is that I want this app to be a one-click deploy to anyone that wants to try it out. From the start, I've tried to ensure that there's minimal setup to get it running. Meaning that I didn't want you to have to have a Firebase or Auth0 account to get it up and running.
I decided that I'd use a regular email/password authentication to authorize users, and then use JWT stored in a cookie for subsequent requests to the backend.
Passport Strategies
In Passport terms, it means we'll be creating a Local strategy, and a JWT strategy. Here's what that looks like:
const bcrypt = require('bcrypt');
const { User } = require('../database/models');
const passport = require('passport');
const localStrategy = require('passport-local').Strategy;
passport.use(
'login',
new localStrategy({
usernameField: 'email',
passwordField: 'password',
session: false
}, (username, password, done) => {
try {
User.findOne({
where: {
email: username
}
}).then(user => {
if (!user) {
return done({ message: 'incorrect email or password' }, false)
}
else {
bcrypt.compare(password, user.password).then(response => {
if (!response) {
return done({ message: 'incorrect email or password' }, false)
}
return done(null, user)
})
}
})
}
catch (err) {
done(err);
}
})
)
This creates a passport strategy that allows us to verify a user when they attempt to login using our /login
route. When we create the login route, we'll specify this strategy as the middleware to check the users' credentials and create the token that will be stored in the browser cookie.
We're using the bcrypt
library to encrypt passwords before storing them.
Next, we'll create a JWT strategy that will verify a JWT token from the cookie.
const jwtSecret = require('./jwtConfig');
const passportJWT = require('passport-jwt');
const JWTStrategy =passportJWT.Strategy;
passport.use(
'jwt',
new JWTStrategy({
jwtFromRequest: req => req.cookies ? req.cookies.jwt : null,
secretOrKey: jwtSecret.secret
},
(jwtPayload, done) => {
if (!jwtPayload) {
console.log('No token')
return done({ error: 'JWT missing' }, false);
}
else if (Date.now() > jwtPayload.expires) {
console.log('Token expired')
return done({ error: 'JWT expired' }, false);
}
return done(null, jwtPayload);
}
)
)
This strategy takes the token from the cookie in the request, verifies it, and returns the payload. This means if we use the token with the user ID as the payload, every request that uses this strategy (i.e. all of our protected routes) will have access to the user ID if the token is valid.
You'll notice we're importing a secret key from a file called ./jwtConfig.js
. We won't actually be storing the key itself in that file as it would be incredibly bad practice. Instead the file will be using a secret key stored in an environment variable. Digital Ocean's App Platform allows us to configure and encrypt environment variables which will make sure our secret key stays secret.
Applying the Strategies to Routes
At the moment, our strategies are good but aren't implemented anywhere in our app. We have to apply the strategies to a route in order for them to work. We'll create some auth controllers to handle authentication:
const jwt = require('jsonwebtoken');
const passport = require('passport');
const db = require('../database/models');
const express = require('express');
const router = express.Router();
router.post('/login', async (req, res) => {
await passport.authenticate(
'login',
{ session: false },
(err, user) => {
if (err || !user) {
console.log('Error authenticating user: ', err);
res.status(400).json({ err });
}
const expires = Date.now() + 24 * 60 * 60 * 7 // One week
const payload = {
id: user.id,
name: user.name,
email: user.email,
expires
}
req.login(payload, { session: false }, error => {
if (error) {
console.log('Error logging in: ', err);
res.status(400).send({ error })
}
const token = jwt.sign(JSON.stringify(payload), secret);
res.cookie('jwt', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: expires
});
const { name, email } = user;
res.status(200).send({ name, email });
});
}
)(req, res)
})
Here, we've defined our /login
route. The login process involves:
- Pass the request/response object to our
passport.authenticate
function - Check if the authentication strategy threw an error and return the error to the frontend if so
- Create the JWT payload and sign it
- Apply the token to a secure cookie on the response
- Return the response to the frontend
Storing the token in a cookie with the httpOnly
option means that the token can only be accessed on the server, not through JavaScript on the browser. This will help protect our app from cross-site scripting attacks and is much more secure that storing the token directly in local storage on the browser. When this cookie is attached, the browser will automatically attach it to subsequent requests to our backend.
Next, we'll take a look at our JWT strategy implementation. After a user has successfully logged in, we'll receive a token in a cookie for all subsequent requests. We'll use our JWT strategy to verify this token before allowing access to our database. Here's an example route:
router.get(
'/auth-user',
(req, res) => {
passport.authenticate(
'jwt',
{ session: false },
(err, user) => {
if (err) {
res.status(401).send({ message: err });
}
else {
res.status(200).send({ user });
}
})(req, res)
});
This is a simple route that checks if the user is logged in. We can use this route to decide on the frontend if we'll show them the logged-in view or the default public view. It simply applies our JWT strategy, which will give us back the user if it is valid, or an error if not. All of our private routes will use this format which will ensure the user is authenticated before we pass any data from our database.
Next, we'll get started taking a look at the meat of our application - our React frontend
Top comments (0)