If you’ve done a good share of web development, there’s a likelihood you have implemented some type of password requirements for new user registration, enforcing certain parameters on the passwords users submit. But where did those requirements come from? What forms have you come across over time? What you think are good password requirements? What we’re actually talking about is password composition. Building out these requirements in a Express.js web application isn’t any different.
Password Composition
So, what is “good” password composition?
Is it this?
Maybe it’s this?
Actually, it’s all of these and more. I’ve talked about good password composition extensively in an article on securing sensitive data, so I won’t go into any elaborate detail. But there are two crucial points to remember::
- All stored password hashes are basically a needle in a haystack.
- The key is how long it takes to find that needle.
The Needle in the Haystack
You’ve probably heard a lot about different kinds of password requirements—special characters, combinations of alphanumeric and upper and lowercase letters. All of these requirements are important and help widen the spectrum of possible passwords. But do you know what single component will have the biggest impact on the security of a password? Surprisingly, it’s length. That’s why, instead of passwords, I advocate for using pass phrases.
You can get a good idea of how it plays into the overall ability to brute force password cracking using the Gibson Research Space Calculator. For example, take the following two password compositions and see how they line up:
Password | Length | Alphanumeric | Upper / Lower | Special | Massive Cracking Array Scenario |
---|---|---|---|---|---|
Leetzsp3k! | 10 | YES | YES | YES | 1 Week |
no soup for you | 15 | NO | NO | NO | 1,000 Centuries |
Yes, you’re reading that correctly. Using an offline, superpower array of GPU’s to brute force crack the second password will take over 1,000 centuries. In contrast, the first password, which has 10 alphanumeric, upper and lowercase letters and special characters,only takes 1 week to crack.
So how do we enforce the proper password requirements in a node.js web application such as in Express.js?
Express Validator
I’m going to show you a two-layer approach to enforcing password requirements—one that lets us validate a new user-submitted account password upon submission, and do a second validation check when saving to a MongoDB database.
Obviously, your node.js/express.js web server of choice might not be Express, but the NPM module we’re going to utilize is a wrapper to the heavily utilized NPM “validator” module, which is not based on any node.js framework. Express-validator provides a number of convenient methods off of the express request object such as checking strictly the body, query and parameters, or all of them. It also provides the ability to define schema’s that leverage the underlying validation methods that validator.js provides along with the ability to provide custom validation methods.
WARNING: For the ease of following along, I have stuffed the logic in the route rather than taking the correct, pragmatic approach of separating it out.
Imagine we have the following express route when a new user attempts to register:
authenticationRouter.route("/api/user/register") .post(cors(), async function (req, res) { try { const User = await getUserModel(); const{email, password, firstName, lastName} = req.body; const existingUser = await User.findOne({username: email}).exec(); if (existingUser) { return res.status(409).send(`The specified email ${email} address already exists.`); } const submittedUser = { firstName: firstName, lastName: lastName, username: email, email: email, password: password, created: Date.now() }; const user = new User(submittedUser); await user.save() .then(function (user) { if (user) { console.log(colors.yellow(`Created User ${JSON.stringify(user)}`)); } }) .catch(function (err) { if (err) { console.log(colors.yellow(`Error occurred saving User ${err}`)); } }); res.status(201).json({user: {firstName: user.firstName, lastName: user.lastName, email: user.email}}); } catch (err) { throw err; } });
If you’re observant, you’ll notice that we are blatantly accepting values the user is submitting, such as the email field. If the user doesn’t exist, we’ll proceed to simply accept the password they provide.
Let’s do something about this by implementing a schema that will define what values are acceptable.
We can create a file validationSchema.js
export const registrationSchema = { "email": { notEmpty: true, isEmail: { errorMessage: "Invalid Email" } }, "password": { notEmpty: true, isLength: { options: [{ min: 12}], errorMessage: "Must be at least 12 characters" }, matches: { options: ["(?=.*[a-zA-Z])(?=.*[0-9]+).*", "g"], errorMessage: "Password must be alphanumeric." }, errorMessage: "Invalid password" } };
Here we have defined an object that sets the rules for two other objects: “email” and “password”.
email:
- can’t be empty
- must confirm to the validator.js rules of a valid email
password:
- can’t be empty
- must have a minimum of 12 characters
- and must be alphanumeric
We have also specified field specific error messages to easily provide feedback to our user on the front end when their submitted email or password doesn’t conform.
Now, we can put this schema to use back in our authentication route for registering a new user:
import {registrationSchema} from "../validation/validationSchemas”; ... authenticationRouter.route("/api/user/register") .post(cors(), async function (req, res) { try { const User = await getUserModel(); req.checkBody(registrationSchema); const errors = req.validationErrors(); if (errors) { return res.status(500).json(errors); } const {email, password, firstName, lastName} = req.body; const existingUser = await User.findOne({username: email}).exec(); if (existingUser) { return res.status(409).send(`The specified email ${email} address already exists.`); } const submittedUser = { firstName: firstName, lastName: lastName, username: email, email: email, password: password, created: Date.now() }; const user = new User(submittedUser); await user.save(); res.status(201).json({user: {firstName: user.firstName, lastName: user.lastName, email: user.email}}); } catch (err) { res.status(500).send("There was an error creating user. Please try again later"); } });
After importing the registrationSchema object we created in the validationSchema.js file, we updated the route to call the checkBody() method off of the request and pass it the registrationSchema object.
Express-validatior will now only look for “email” and “password” properties on the request body and if they exist, will proceed to apply the rules that we defined in the schema for each of these fields.
If there are errors, when we call the validationErrors() method off of the request object req.validationErrors(), we can acquire any errors that were found with the submitted values on the request.
But like any security, multiple layers can help us in the case that a mitigation breaks down.
Multi-layer Security with Mongoose and MongoDB
If you’re not working with MongoDB or the Object Data Modeling tool Mongoose, you can still use the following as a guide for implementing a database validation layer in whatever database you’re working with.
If you have worked with Mongoose before, you’re familiar with defining schemas and defining the shape of the data you’re saving to a MongoDB database. Take for instance our User Schema:
const UserSchema = new Schema({ firstName: String, lastName: String, username: { type: String, index: { unique: true } }, password: { type: String, required: true, match: /(?=.*[a-zA-Z])(?=.*[0-9]+).*/, minlength: 12 }, email: { type: String, require: true, match: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i }, created: { type: Date, required: true, default: new Date() } });
I have reduced noise by only showing validation rules for the fields we were concentrating on password and email. But we can see here that we are implementing the same rules we were enforcing on the form fields that were being submitted by the user when registering.
Then, back in our new user registration route:
authenticationRouter.route("/api/user/register") .post(cors(), async function (req, res) { try { //…removed for brevity const user = new User(submittedUser); await user.save(); res.status(201).json({user: {firstName: user.firstName, lastName: user.lastName, email: user.email}}); } catch (err) { res.status(500).send("There was an error creating user. Please try again later"); } });
When we call user.save() mongoose will enforce our schema rules we defined above and will throw an error if they don’t conform. In this way, we have yet one more place of validation before we push this data into our backend storage.
In a future post regarding access controls, we’ll see how we can utilize a routing hook to move validation checks such as these to a point that’s specific to a route, yet further way from our critical systems, such as a database.
“Securing Node.js: User Account Password Requirements in Express.js” also published on Summa.com