Node.js Passport and Terms of Use: sign in to MyPortfolio

In my application named MyPortfolio (built with Node.js/Express and MongoDB), I decided to use Passport as authentication middleware.

It is extremely flexible and modular, and it comes with a comprehensive set of strategies which support authentication using a username and password, Google-OAuth, Facebook, Twitter, and more.

I started developing a Passport authentication, by following a very interesting tutorial of Scotch.io, where it is clearly illustrated how to use Passport to perform local authentication, Facebook, Twitter, Google authentications, and even how to link all social accounts under one user account.

But there was something lacking in that tutorial, and many other Passport tutorials I found on the web: how could I use Passport middleware to perform a sign-up only after user viewed and accepted my application’s Terms of Use?

Let me show you the architecture, the models and the operations flow I used (check also the code here MyPortfolio code).

Package.json

Here are the dependencies I’m using in package.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// package.json
...
"dependencies": {
"async": "^2.5.0",
"bcrypt-nodejs": "0.0.3",
"body-parser": "^1.18.2",
"connect-flash": "^0.1.1",
"cookie-parser": "^1.4.3",
"dotenv": "^4.0.0",
"ejs": "^2.5.7",
"express": "^4.14.0",
"express-ejs-layouts": "^2.3.1",
"express-session": "^1.15.6",
"express-validator": "^4.2.1",
"fs-extra": "^4.0.2",
"mongoose": "^4.11.13",
"multer": "^1.3.0",
"passport": "^0.4.0",
"passport-facebook": "^2.1.1",
"passport-google-oauth": "^1.0.0",
"passport-local": "^1.0.0",
"passport-twitter": "^1.0.4",
"summernote-nodejs": "^1.0.4"
}
...

Because of I decided to let users log in only with their Google accounts in MyPortfolio, the dependencies of interest in this post are:

  • express is the framework.
  • express-session to handle the session in Express framework.
  • ejs is the templating engine.
  • mongoose is the object modeling for our MongoDB database.
  • passport and passport-google-oauth for Google authentication.
  • connect-flash allows to pass session flashdata messages.
  • async lets easily perform asynchronous operations.
  • fs-extra empowers Node.js fs library with Promises.

User model

Here is the user model schema, which resembles the one in the Scotch.io tutorial.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// user.js
...
var userSchema = mongoose.Schema({
local : {
email : String,
password : String,
},
facebook : {
id : String,
token : String,
email : String,
name : String
},
twitter : {
id : String,
token : String,
displayName : String,
username : String
},
google : {
id : String,
token : String,
email : {type: String, index: true},
name : {type: String, index: true},
imageUrl : String,
eslug : String
},
mustacceptterms : {
type: Boolean,
default: 'false'
}
});
...

You can notice that in the userSchema I added another boolean parameter, named mustacceptterms: this one will be true until the user will have explicitly confirmed to have read and accepted the Terms of Use.

Application setup

server.js contains the setup of the different packages, included Express, Passport, Session and Mongoose

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// server.js
// load environment variables
require('dotenv').config();
// grab our dependencies
const express = require('express'),
app = express(),
port = process.env.PORT || 8080
expressLayouts = require('express-ejs-layouts'),
mongoose = require('mongoose'),
passport = require('passport'),
bodyParser = require('body-parser'),
session = require('express-session'),
cookieParser = require('cookie-parser'),
flash = require('connect-flash'),
expressValidator = require('express-validator');
// configure our application
// set sessions and cookie parser
app.use(cookieParser());
app.use(session({
secret: process.env.SECRET,
cookie: {maxAge: 8*60*60*1000}, // 8 hours
resave: false, // forces the session to be saved back to the store
saveUninitialized: false // don't save unmodified sessions
}));
app.use(flash());
// tell express where to look for static assets
app.use(express.static(__dirname + '/public'));
// set ejs as templating engine
app.set('view engine', 'ejs');
app.use(expressLayouts);
// connect to the database
mongoose.connect(process.env.DB_URI);
// use bodyParser to grab info from a json
app.use(bodyParser.json({limit: '50mb'}));
// use bodyParser to grab info from a form
app.use(bodyParser.urlencoded({limit: '50mb', extended: true}));
// express validator -> validate form or URL parameters
app.use(expressValidator());
app.use(passport.initialize());
app.use(passport.session()); // persistent login sessions
require('./config/passport')(passport); // pass passport for configuration
// set the routes
app.use(require('./app/routes'));
// start our server
app.listen(port, () => {
console.log(`App listening on http://localhost:${port}`);
});

Passport Configuration

The passport object is passed as parameter to config/passport.js where the Google OAuth strategy will be configured:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// config/passport.js
// load only the Google strategy, because the login to My-Portfolio
// will be only possible by using the Google account
var GoogleStrategy = require('passport-google-oauth').OAuth2Strategy;
// load up the user model
var User = require('../app/models/user');
const initUserFolderStructure = require('../app/utilities').initUserFolderStructure;
module.exports = function(passport) {
// =========================================================================
// passport session setup ==================================================
// =========================================================================
// required for persistent login sessions
// passport needs ability to serialize and unserialize users out of session
// used to serialize the user for the session
passport.serializeUser(function(user, done) {
done(null, user.id);
});
// used to deserialize the user
passport.deserializeUser(function(id, done) {
User.findById(id, function(err, user) {
done(err, user);
});
});
// =========================================================================
// GOOGLE ==================================================================
// =========================================================================
passport.use(new GoogleStrategy({
clientID : process.env.GOOGLEAUTH_clientID,
clientSecret : process.env.GOOGLEAUTH_clientSecret,
callbackURL : process.env.GOOGLEAUTH_callbackURL,
passReqToCallback : true // allows us to pass in the req from our route (lets us check if a user is logged in or not)
},
...

As shown above, passport will use only the Google Oauth strategy, and the interesting part regards the callback invoked after authentication succeeds:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
...
function(req, token, refreshToken, profile, done) {
// check if the user is already logged in
if (!req.user) {
User.findOne({ 'google.id' : profile.id }, function(err, user) {
if (err) {
return done(err);
}
if (user) {
// if there is a user id already but no token (user was linked at one point and then removed)
if (!user.google.token) {
user.google.token = token;
user.google.name = profile.displayName;
user.google.email = profile.emails[0].value; // pull the first email
user.google.imageUrl = profile.photos[0].value; // pull the first image
initUserFolderStructure(profile.id, () => {
user.save(function(err) {
if (err) {
throw err;
}
return done(null, user);
});
});
}
if(!user.mustacceptterms) {
initUserFolderStructure(profile.id, () => {
user.save(function(err) {
if (err) {
throw err;
}
return done(null, user);
});
});
}
else {
return done(null, user);
}
}
// brand new user
else {
var newUser = new User();
newUser.google.id = profile.id;
newUser.google.token = token;
newUser.google.name = profile.displayName;
newUser.google.email = profile.emails[0].value; // pull the first email
newUser.google.imageUrl = profile.photos[0].value; // pull the first image
newUser.mustacceptterms = true; // the new user must accept terms of use before creating the account on MyPortfolio
newUser.save(function(err) {
if (err) {
throw err;
}
return done(null, newUser);
});
}
});
}
else {
// user already exists and is logged in, we have to link accounts
var user = req.user; // pull the user out of the session
user.google.id = profile.id;
user.google.token = token;
user.google.name = profile.displayName;
user.google.email = profile.emails[0].value; // pull the first email
user.google.imageUrl = profile.photos[0].value; // pull the first image
initUserFolderStructure(profile.id, () => {
user.save(function(err) {
if (err) {
throw err;
}
return done(null, user);
});
});
}
}));

If a user logs in to MyPortfolio for the first time (‘A brand new user’), he will be saved to the database with the flag mustacceptterms set to true, and no folder structure will be created.

Even if the user had previously logged in without accepting the Terms of Use, he would just be considered authenticated, but the flag mustacceptterms would remain set to true.

So I added two different functions to determine if user is really logged-in, or if he’s just authenticated, and the flag mustacceptterms is the determiner.

Handling routing

The routes.js contains the authentication logic (and much more):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// routes.js
// create a new express Router
const express = require('express'),
router = express.Router(),
multer = require('multer'),
multerupload = multer({
dest: 'uploads/',
limits: { fieldSize: 25 * 1024 * 1024 }
}),
mainController = require('./controllers/main.controller'),
authController = require('./controllers/auth.controller'),
portfoliosController = require('./controllers/portfolios.controller');
const fs = require('fs');
// route middleware to make sure a user is logged in
function isLoggedIn(req, res, next) {
// if user is authenticated in the session and the terms of use have been accepted, pass the control to the "next()" handler
if (req.isAuthenticated() && !req.user.mustacceptterms) {
return next();
}
// otherwise redirect him to the home page
res.redirect('/');
}
// route middleware to make sure a user is temporarily logged in to accept terms of use
function isLoggedInToAcceptTerms(req, res, next) {
//console.log(req.user);
// if user is authenticated in the session and the terms of use have not been accepted, pass the control to the "next()" handler
if (req.isAuthenticated() && req.user.mustacceptterms) {
return next();
}
// otherwise redirect him to the home page
res.redirect('/');
}
// export router
module.exports = router;
// define routes
// main routes
router.get('/', mainController.showHome);
// route for showing the "Terms of Use" page
router.get('/termsofuse', mainController.showTermsOfUse);
// Google OAuth authentication
router.get('/auth/google', passport.authenticate('google', { scope : ['profile', 'email'] }));
// the callback after Google has authenticated the user
router.get('/auth/google/callback',
passport.authenticate('google', {failureRedirect : '/'}), // on failure Google authentication
function(req, res) { // on correct Google authentication
// User must accept the terms of use before creating the account
if(req.user.mustacceptterms) {
fs.readFile('./public/assets/termsOfUse.html', (err, data) => {
if (err) throw err;
res.render('pages/acceptTermsOfUse', {
user : req.user,
termscontent: data
});
});
}
// User has already accepted the terms of use
else {
res.redirect('/portfolios/editPortfolioInfos');
}
});
// route for showing the profile page
router.get('/account', isLoggedIn, authController.showAccount);
// route for handling the terms of use acceptance
router.post('/accepttermsofuse', isLoggedInToAcceptTerms, authController.processAcceptTermsOfUse);
...

The utility function isLoggedIn determines if a user is logged in the application.

The utility function isLoggedInToAcceptTerms determines if a user is just authenticated (but still never accepted the Terms of Use).

The route ‘/auth/google’ uses passport authentication method (which was previously configured with the Google Oauth2 Strategy) to authenticate the user with his Google account.

The magic happens for the route ‘/auth/google/callback’, which represents the callback invoked after the Google authentication procedure. I used a ‘failureRedirect’ to the home page when the authentication fails, and a Custom function on correct Google authentication.

Thanks to this custom function I can determine whether the Terms of Use haven’t been previously accepted, and in that case redirect the user to the Terms of Use page: from there the user will have the chance to read and accept the Terms and complete the sign-in process.