Hashing and JWTs with Node
February 17, 2020
The goals of this lecture are:
- Hash passwords with Bcrypt
- Use JSON Web Tokens for API authentication
- Use middleware to simplify route security
bcrypt Review
We've used bcrypt
already during our previous work with Flask, but we'll be revisiting it again in Node.
Recall that bcrypt
is a cryptographic hashing function that utilizes a salt (random string) to produce a sufficiently complex password.
Installing bcrypt
To install, we head to the root of our project and run:
$ npm install bcrypt
Then, in the file that we'd like to utilize it, we import the module:
const bcrypt = require("bcrypt");
Password Hashing
Using our bcrypt
module, there are a few methods to keep in mind:
bcrypt.hash(password-to-hash, work-factor)
The .hash()
method will hash a password, using a specified work factor, or a number that will determine how "expensive" the hash function will be. 12 is a good number.
This method will returns a Promise, one which we will resolve in order to get the resulting hashed password.
bcrypt.compare(password, hashed-password)
The .compare()
method will check if the provided password is valid.
This method will also return a Promise, which, once resolved, will give us a Boolean value.
Using bcrypt
Let's take a look at a couple example routes for registering and authenticating users.
Creating New User
In our registration route, the end user submits a username
and password
to be associated with their account.
Before storing this information to the database, we first hash the plain text password using bcrypt
, storing the resulting hashed password into the new record afterward:
router.post("/register", async function (req, res, next) {
const { username, password } = req.body;
const hashedPassword = await bcrypt.hash(password, 12);
const result = await db.query(
`INSERT INTO users (username, password)
VALUES ($1, $2)
RETURNING username`,
[username, hashedPassword]);
return res.json(result.rows[0]);
});
Logging in Existing User
For users that already have an account, our logic will look a bit different.
We first want to ensure that the user actually exists in our database.
In the case that the user record has been found and retrieved, we then use bcrypt
to hash the password that was passed in by the user.
With the hashed password in hand, we then use the .compare()
methid to verify that the hashed password matches with the (also hashed) version stored in the database.
router.post("/login-1", async function (req, res, next) {
try {
const { username, password } = req.body;
const result = await db.query(
`SELECT password FROM users WHERE username = $1`,
[username]);
const user = result.rows[0];
if (user) {
if (await bcrypt.compare(password, user.password) === true) {
return res.json({ message: "Logged in!" });
}
}
throw new ExpressError("Invalid user/password", 400);
} catch (err) {
return next(err);
}
});
Authentication Lookback
Let's think back to our previous approach to storing session information in Flask.
In our application, the user would submit their username and password, which we'd authenticate on the server. If correct credentials, we placed the user information into the session.
That session was then encoded and signed with Flask-specific scheme. The session information was then sent back to the user's browser in the form of a cookie. For every subsequent request sent to the browser, this cookie information was sent, as well.
This approach is straightforward and works well in traditional web applications, but what if we didn't want to send authentication information with every single request? Additionally, what if we wanted to share authentication information across multiple APIs and/or hostnames?
To do so, we're going to use a more API-server-friendly process.
Authentication via Tokens
The approach that we'll take in our Node applications going forward will look a bit different than what we've used for Flask in the past.
Our user will make a request to authenticate, passing to our API a username and a password. On the server, we'll verify that these credentials are correct and return a token within our JSON.
This token will be encoded and signed with the open standard JSON Web Token.
Upon receiving this token from our API, our frontend will store this information in a variable or localStorage
.
For every request that is made from that point out, this token will be sent with the request.
When the request reaches the server, we verify that the token is valid and, if so, execute the request.
JSON Web Tokens (JWTs)
JSON Web Tokens are an open standard that are implemented and used with different technology stacks. This makes it easy for us to use and share JWTs across services.
JWTs can store information of our choosing in the payload
, which is then “signed” using a secret key. This secret key is then used for validatation later on (similar to our work with Flask’s session
).
JWT Anatomy
A JWT is made up of three parts:
- Header. Metadata about the token, including such information as the algorithm used to sign it and the type of token.
- Payload. Data to be stored in the token, like the user name or ID. This information is encoded, not encrypted.
- Signature. Using the algorithm specified in the header, this contains the version of the header and payload. It is signed with the secret key.
Using JWTs
To use JWTs within our Node application, we'll install the jsonwebtoken
package:
$ npm install jsonwebtoken
Creating Tokens
To create a JWT, we'll use the jwt.sign()
method, passing into it a few arguments and being returned a token (a string).
The arguments include:
- payload An object to store as payload of the token
- secret-key The secret string used to “sign” token
- jwt-options This optional object contains settings for making the token
Let's look at an example:
const jwt = require("jsonwebtoken");
const SECRET_KEY = "oh-so-secret";
const JWT_OPTIONS = { expiresIn: 60 * 60 }; // 1 hour
let payload = { username: "alissa"};
let token = jwt.sign(payload, SECRET_KEY, JWT_OPTIONS);
Decoding/Verifying Tokens
To decode a token, we'll use the .decode()
method, passing in the received token. This method will return the payload from the token, even without the secret key.
Keep in mind, the tokens are signed, not enciphered!
To verify that a token signature is legit, we use the .verify()
method, passing into it both the received token and the secret key.
This method will verify that the token signature is as expected and will confirm that the payload is valid.
If it's not valid, or it has been otherwise tampered with, it will raise error.
jwt.decode(token); // { username: "alissa" }
jwt.verify(token, SECRET_KEY); // { username: "alissa" }
jwt.verify(token, "WRONG"); // error!
Using JWTs in Express
Let's take a look at how we might incorporate JWTs into an Express application:
router.post("/login", async function (req, res, next) {
try {
const { username, password } = req.body;
const result = await db.query(
"SELECT password FROM users WHERE username = $1",
[username]);
let user = result.rows[0];
if (user) {
if (await bcrypt.compare(password, user.password) === true) {
let token = jwt.sign({ username }, SECRET_KEY);
return res.json({ token });
}
}
throw new ExpressError("Invalid user/password", 400);
} catch (err) {
return next(err);
}
});
In our /login
route, a user will send with the request their username and password.
We'll verify that the user record exists, querying to return the associated hashed password, if so.
If the user exists, we'll then use the stored password to compare the password submitted by the user to what's been stored using bcrypt.compare()
.
Assuming everything checks out, we then create a new JWT using the jwt.sign()
method, passing in the username and our SECRET_KEY
value.
The route will then return the resulting token, which the frontend will store to be sent with any subsequent requests that the user makes to our API.
Protected Routes in Express
As we pointed out above, after a token is created and stored in the frontend, that same token should be sent with any request that requires authentication.
As such, our protected routes will need to extract out and verify the token that was sent with the request:
router.get("/secret-route", async function (req, res, next) {
try {
const tokenFromBody = req.body._token;
// verify this was a token signed with OUR secret key
// jwt.verify raises error, if not
jwt.verify(tokenFromBody, SECRET_KEY);
return res.json({ message: "Made it!" });
}
catch (err) {
return next({ status: 401, message: "Unauthorized" });
}
});
This works just fine, but we want to keep our code DRY: we do not want to have to write this same logic in each of our routes.
So, let's write some middleware!
Authentication Middleware
In a separate module, we'll write our JWT authentication middleware:
function authenticateJWT(req, res, next) {
try {
const tokenFromBody = req.body._token;
const payload = jwt.verify(tokenFromBody, SECRET_KEY);
req.user = payload;
return next();
} catch (err) {
// error in this middleware isn't error -- continue on
return next();
}
}
As seen above, this function:
- Extracts out the
_token
from the request sent by the user - Verifies that the token is valid, and
- If valid, sets the request's
user
key to the payload of the JWT
err
to the next()
function. In this particular case, we are just validating the JWT. If the JWT is not valid, our other middleware and/or routes will be responsible for handling the appropriate response(s).
Meanwhile, since we'd like this middleware to be applied to all requests made to all routes within our application, we specify as much using the app.use()
method in our routes file:
const express = require("express");
const app = express();
const routes = require("./routes/auth");
const ExpressError = require("./expressError");
const { authenticateJWT } = require("./middleware/auth");
app.use(express.json());
app.use(authenticateJWT);
Authorization Middleware
Another piece of middleware we'd like to apply is one that authorizes a user, based on the current state of req.user
.
Within our authenticateJWT
middleware, only if the JWT is valid does req.user
get set with user information.
In our authorization middleware, an absence of this information means the JWT is expired or otherwise invalid, and therefore the user is not logged in:
function ensureLoggedIn(req, res, next) {
if (!req.user) {
const err = new ExpressError("Unauthorized", 401);
return next(err);
} else {
return next();
}
}
And, it could be the case that we want to be more specific with our authentication, ensuring that the user in question has a particular role assigned to them.
In the case below, we're checking to see if the user sending the request has a role of admin
. If so, they can continue onto the requested route:
function ensureAdmin(req, res, next) {
if (!req.user || req.user.username != "admin") {
const err = new ExpressError("Unauthorized", 401);
return next(err);
} else {
return next();
}
}
When dealing with user authentication, we're unlikely to apply this type of middleware to all routes, and will instead do so on a route-by-route basis:
router.get("/secret",
ensureLoggedIn, async function (req, res, next) {
try {
return res.json({ message: "Made it!" });
} catch (err) {
return next(err);
}
});
Common Configuration
You may have noticed in the previous examples that we used a shared, constant variable for our SECRET_KEY
.
The reasoning behind this is that as our application grows, this will be used in multiple places. Because we always strive to keep our code DRY, we will define it once and import it where necessary.
A separate file called config.js
is a suitable home for this, and will likely contain other configuration-specific items, like our database URIs and the bcrypt
work factor for hashing our passwords:
const DB_URI = (process.env.NODE_ENV === "test")
? "postgresql:///express_hashing_jwts_test"
: "postgresql:///express_hashing_jwts";
const SECRET_KEY = process.env.SECRET_KEY || "secret";
const BCRYPT_WORK_FACTOR = 12;
module.exports = {
DB_URI,
SECRET_KEY,
BCRYPT_WORK_FACTOR
};
Testing Authentication
After we've introduced authentication into our Express application, we'll want to incorporate this functionality into our tests.
Before each request, we'll create test users, along with valid tokens for each. These tokens (and users) will be stored in global variables, accessible across all of the tests we'll run.
beforeEach
Before every test is run:
let testUserToken;
let testAdminToken;
beforeEach(async function () {
const hashedPassword = await bcrypt.hash(
"secret", BCRYPT_WORK_FACTOR);
await db.query(`INSERT INTO users VALUES ('test', $1)`,
[hashedPassword]);
await db.query(`INSERT INTO users VALUES ('admin', $1)`,
[hashedPassword]);
// we'll need tokens for future requests
const testUser = { username: "test" };
const testAdmin = { username: "admin" };
testUserToken = jwt.sign(testUser, SECRET_KEY);
testAdminToken = jwt.sign(testAdmin, SECRET_KEY);
});
Testing Protected Route Behavior
To ensure that users with valid JWTs can access protected routes:
describe("GET /secret success", function () {
test("returns 'Made it'", async function () {
const response = await request(app)
.get(`/secret`)
.send({ _token: testUserToken });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ message: "Made it!" });
});
});
As well as users with invalid JWTs being denied access to protected routes:
describe("GET /secret failure", function () {
test("returns 401 when logged out", async function () {
const response = await request(app)
.get(`/secret`); // no token being sent!
expect(response.statusCode).toBe(401);
});
test("returns 401 with invalid token", async function () {
const response = await request(app)
.get(`/secret`)
.send({ _token: "garbage" }); // invalid token!
expect(response.statusCode).toBe(401);
});
});