- MEAN Blueprints
- Robert Onodi
- 2387字
- 2021-07-16 10:40:20
Securing your application routes
You probably don't want to let anyone see your contacts, so it's time to secure your endpoints. There are many strategies that we can use to authenticate trusted users in an application. We are going to use a classic, state-full e-mail and password based authentication. This means that the session will be stored on the server side.
Remember we discussed at the beginning of the chapter how we are going to store our session on the server side? We choose two integrations, one with default in-memory session management and one that stores sessions in MongoDB. Everything is configurable from the environment configuration file.
When it comes to handling authentication in Node.js, a good go-to module is Passport, which is a piece of authentication middleware. Passport has a comprehensive set of authentication strategies using a simple username-and-password combination for Facebook, Google, Twitter, and many more.
We have already added this dependency to our application and made the necessary initializations in the express configuration file. We still need to add a few things, but before that, we have to create some reusable components in our backend application. We are going to create a helper file that will ease our interactions with passwords.
Describing the password helper
Before we dive deeper into the authentication mechanism, we need to be able to store in MongoDB a password hash instead of the plain password. We want to create a helper for this task that enables us to make operations related to passwords.
Create a new folder in the tests
folder, named unit
. Add a new file, contact-manager/tests/unit/password.test.js
, and then add the following code to it:
'use strict'; const chai = require('chai'); const should = chai.should(); const passwordHelper = require('../../app/helpers/password'); describe('Password Helper', () => { });
In our main description body, we are going to add segments that represent our features in more detail. Add this code:
describe('#hash() - password hashing', () => { }); describe('#verify() - compare a password with a hash', () => { });
Mocha also provides an it()
function, which we are going to use to set up a concrete test. The it()
function is very similar to describe()
, except that we put only what the feature is supposed to do. For assertion, we are going to use the Chai library. Add the following code to the tests/unit/password.test.js
file:
describe('#hash() - password hashing', () => { it('should return a hash and a salt from a plain string', (done) => { passwordHelper.hash('P@ssw0rd!', (err, hash, salt) => { if (err) throw err; should.exist(hash); should.exist(salt); hash.should.be.a('string'); salt.should.be.a('string'); hash.should.not.equal('P@ssw0rd!'); done(); }); }); it('should return only a hash from a plain string if salt is given', (done) => { passwordHelper.hash('P@ssw0rd!', 'secret salt', (err, hash, salt) => { if (err) throw err; should.exist(hash); should.not.exist(salt); hash.should.be.a('string'); hash.should.not.equal('P@ssw0rd!'); done(); }); }); it('should return the same hash if the password and salt ar the same', (done) => { passwordHelper.hash('P@ssw0rd!', (err, hash, salt) => { if (err) throw err; passwordHelper.hash('P@ssw0rd!', salt, function(err, hashWithSalt) { if (err) throw err; should.exist(hash); hash.should.be.a('string'); hash.should.not.equal('P@ssw0rd!'); hash.should.equal(hashWithSalt); done(); }); }); }); });
The passwordHelper
should also test whether a password matches the given hash and salt combo. For this, we are going to add the following describe method:
describe('#verify() - compare a password with a hash', () => { it('should return true if the password matches the hash', (done) => { passwordHelper.hash('P@ssw0rd!', (err, hash, salt) => { if (err) throw err; passwordHelper.verify('P@ssw0rd!', hash, salt, (err, result) => { if (err) throw err; should.exist(result); result.should.be.a('boolean'); result.should.equal(true); done(); }); }); }); it('should return false if the password does not matches the hash', (done) => { passwordHelper.hash('P@ssw0rd!', (err, hash, salt) => { if (err) throw err; passwordHelper.verify('password!', hash, salt, (err, result) => { if (err) throw err; should.exist(result); result.should.be.a('boolean'); result.should.equal(false); done(); }); }); }); });
Implementing the password helper
We will implement our password helper in the following file: contact-manager/app/helpers/password.js
.
The first description of our password helper describes a function that creates a hash from a plain password. In our implementation, we will use a key derivation function that will compute a hash from our password, also known as key stretching.
We are going to use the pbkdf2
function from the built-in Node.js crypto
library. The asynchronous version of the function takes a plain password and applies an HMAC digest function. We will use sha256
to get a derived key of a given length, combined with a salt through a number of iterations.
We want to use the same hashing function for both cases: when we already have a password hash and a salt and when we have only a plain password. Let's see the final code for our hashing function. Add the following:
'use strict'; const crypto = require('crypto'); const len = 512; const iterations = 18000; const digest = 'sha256'; module.exports.hash = hashPassword; module.exports.verify = verify; function hashPassword(password, salt, callback) { if (3 === arguments.length) { crypto.pbkdf2(password, salt, iterations, len, digest, (err, derivedKey) => { if (err) { return callback(err); } return callback(null, derivedKey.toString('base64')); }); } else { callback = salt; crypto.randomBytes(len, (err, salt) => { if (err) { return callback(err); } salt = salt.toString('base64'); crypto.pbkdf2(password, salt, iterations, len, digest, (err, derivedKey) => { if (err) { return callback(err); } callback(null, derivedKey.toString('base64'), salt); }); }); } }
Let's see what we get if we run our tests now. Run the following command:
$ mocha tests/unit/password.test.js
The output should be similar to this:
Password Helper #hash() - password hashing √ should return a hash and a salt from a plain string (269ms) √ should return only a hash from a plain string if salt is given (274ms) √ should return the same hash if the password and salt are the same (538ms) 3 passing (2s)
As you can see, we have successfully implemented our hashing function. All the requirements from the test case have passed. Notice that it takes up to 2 seconds to run the tests. Don't worry about this; it's because of the key stretching function taking time to generate the hash from the password.
Next, we are going to implement the verify()
function, which checks whether a password matches an existing user's password-hash-and-salt combination. From the description in our tests, this function accepts four parameters: the plain password, a hash that was generated using the third salt parameter, and a callback function.
The callback gets two arguments: err
and result
. The result
can be true
or false
. This will reflect whether the password matches the existing hash or not. Considering the constraints from the tests and the preceding explanation, we can append the following code to our password.helpr.js
file:
function verify(password, hash, salt, callback) { hashPassword(password, salt, (err, hashedPassword) => { if (err) { return callback(err); } if (hashedPassword === hash) { callback(null, true); } else { callback(null, false); } }); }
By now, we should have implemented all the specifications from our tests.
Creating the user Mongoose schema
In order to grant access to users in the application, we need to store them in a MongoDB collection. We'll create a new file called contact-manager/app/models/user.model.js
and add the following code:
'use strict'; const mongoose = require('mongoose'); const passwordHelper = require('../helpers/password'); const Schema = mongoose.Schema; const _ = require('lodash'); var UserSchema = new Schema({ email: { type: String, required: true, unique: true }, name: { type: String }, password: { type: String, required: true, select: false }, passwordSalt: { type: String, required: true, select: false }, active: { type: Boolean, default: true }, createdAt: { type: Date, default: Date.now } });
The following table gives a description of the fields in the schema:

We'll describe a user authentication method. It will check whether a user has valid credentials. The following file, contact-manager/tests/integration/user.model.test.js
, should contain all the test cases regarding the User
model. These lines of code will test the authenticate()
method:
it('should authenticate a user with valid credentials', done => { User.authenticate(newUserData.email, newUserData.password, (err, user) => { if (err) throw err; should.exist(user); should.not.exist(user.password); should.not.exist(user.passwordSalt); user.email.should.equal(newUserData.email); done(); }); }); it('should not authenticate user with invalid credentials', done => { User.authenticate(newUserData.email, 'notuserpassowrd', (err, user) => { if (err) throw err; should.not.exist(user); done(); }); });
Mongoose lets us add static methods to compiled models from schemas. The authenticate()
method will search for a user in the database by its e-mail and use the password helper's verify()
function to check whether the sent password is a match.
Add the following lines of code to the contact-manager/app/models/user.js
file:
UserSchema.statics.authenticate = authenticateUser; function authenticateUser(email, password, callback) { this .findOne({ email: email }) .select('+password +passwordSalt') .exec((err, user) => { if (err) { return callback(err, null); } // no user found just return the empty user if (!user) { return callback(err, user); } // verify the password with the existing hash from the user passwordHelper.verify( password, user.password, user.passwordSalt, (err, result) => { if (err) { return callback(err, null); } // if password does not match don't return user if (result === false) { return callback(err, null); } // remove password and salt from the result user.password = undefined; user.passwordSalt = undefined; // return user if everything is ok callback(err, user); } ); }); }
In the preceding code, when selecting the user from MongoDB, we explicitly selected the password and passwordSalt
fields. This was necessary because we set the password and passwordSalt
fields to not be selected in the query result. Another thing to note is that we want to remove the password and salt from the result when returning the user.
Authentication routes
In order to authenticate in the system we are building, we need to expose some endpoints that will execute the necessary business logic to authenticate a user with valid credentials. Before jumping into any code, we are going to describe the desired behavior.
We are only going to take a look at a partial code from the integration test of the authentication functionality, found in contact-manager/tests/integration/authentication.test.js
. It should look something like this:
describe('Sign in user', () => { it('should sign in a user with valid credentials', (done) => { request({ method: 'POST', url: baseUrl + '/auth/signin', form: { 'email': userFixture.email, 'password': 'P@ssw0rd!' }, json:true }, (err, res, body) => { if (err) throw err; res.statusCode.should.equal(200); body.email.should.equal(userFixture.email); should.not.exist(body.password); should.not.exist(body.passwordSalt); done(); }); }); it('should not sign in a user with invalid credentials', (done) => { request({ method: 'POST', url: baseUrl + '/auth/signin', form: { 'email': userFixture.email, 'password': 'incorrectpassword' }, json:true }, (err, res, body) => { if (err) throw err; res.statusCode.should.equal(400); body.message.should.equal('Invalid email or password.'); done(); }); }); });
So, we've described an auth/signin
endpoint; it will authenticate a user using an e-mail-and-password combination. We are testing two scenarios. The first one is when a user has valid credentials and the second is when an incorrect password is sent.
We mentioned Passport earlier in the chapter and added some basic logic for this purpose, but we still need to make a proper integration. The Passport module should already be installed and the session management is already in place. So next, we need to create a proper configuration file, contact-manager/config/passport.js
, and add the following:
'use strict'; const passport = require('passport'); const mongoose = require('mongoose'); const User = mongoose.model('User'); module.exports.init = initPassport; function initPassport(app) { passport.serializeUser((user, done) => { done(null, user.id); }); passport.deserializeUser((id, done) => { User.findById(id, done); }); // load strategies require('./strategies/local').init(); }
For each subsequent request, we need to serialize and deserialize the user instance to and from the session. We are only going to serialize the user's ID into the session. When subsequent requests are made, the user's ID is used to find the matching user and restore the data in req.user
.
Passport gives us the ability to use different strategies to authenticate our users. We are only going to use e-mail and password to authenticate a user. To keep everything modular, we are going to move the strategies into separate files. The so-called local strategy, which will be used to authenticate users using an e-mail and a password, is going to be in the contact-manager/config/strategies/local.js
file:
'use strict'; const passport = require('passport'); const LocalStrategy = require('passport-local').Strategy; const User = require('mongoose').model('User'); module.exports.init = initLocalStrategy; function initLocalStrategy() { passport.use('local', new LocalStrategy({ usernameField: 'email', passwordField: 'password' }, (email, password, done) => { User.authenticate(email, password, (err, user) => { if (err) { return done(err); } if (!user) { return done(null, false, { message: 'Invalid email or password.' }); } return done(null, user); }); } )); }
Now that we have passport up and running, we can define our authentication controller logic and a proper route to sign in users. Create a new file called contact-manager/app/controllers/authentication.js
:
'use strict'; const passport = require('passport'); const mongoose = require('mongoose'); const User = mongoose.model('User'); module.exports.signin = signin; function signin(req, res, next) { passport.authenticate('local', (err, user, info) => { if (err) { return next(err); } if (!user) { return res.status(400).send(info); } req.logIn(user, (err) => { if (err) { return next(err); } res.status(200).json(user); }); })(req, res, next); }
Here, we use the .authenticate()
function from Passport to check a user's credentials using the local strategy implemented earlier. Next, we are going to add the authentication route, create a new file called contact-manager/app/routes/auth.js
, and add the following lines of code:
'use strict'; var express = require('express'); var router = express.Router(); var authCtrl = require('../controllers/authentication'); router.post('/signin', authCtrl.signin); router.post('/register', authCtrl.register); module.exports = router;
Note that we skipped the register user functionality, but don't worry! The final bundled project source code will have all of the necessary logic.
Restricting access to contacts routes
We created all the requirements to authenticate our users. Now it's time to restrict access to some of the routes, so technically we are going to create a simple ACL. To restrict access, we are going to use a piece of middleware that will check whether users are authenticated or not.
Let's create our middleware file, contact-manager/app/middlewares/authentication.js
. This should contain these lines of carefully crafted code:
'use strict'; module.exports.ensured = ensureAuthenticated; function ensureAuthenticated(req, res, next) { if (req.isAuthenticated()) { return next(); } res.status(401).json({ message: 'Please authenticate.' }); }
We have already added the necessary logic to restrict users to the contact routes; that was when we first created them. We succeeded in adding all the necessary pieces of code to manage contacts and restrict access to our endpoints. Now we can continue and start building our Angular 2 application.
- HTML5+CSS3+JavaScript從入門到精通:上冊(微課精編版·第2版)
- Learning Single:page Web Application Development
- Reporting with Visual Studio and Crystal Reports
- 數據庫系統教程(第2版)
- GraphQL學習指南
- 架構不再難(全5冊)
- Getting Started with SQL Server 2012 Cube Development
- Python漫游數學王國:高等數學、線性代數、數理統計及運籌學
- Bootstrap 4:Responsive Web Design
- 碼上行動:用ChatGPT學會Python編程
- NoSQL數據庫原理
- 軟件測試實用教程
- Scala for Machine Learning(Second Edition)
- INSTANT Yii 1.1 Application Development Starter
- 零基礎學Kotlin之Android項目開發實戰