From 1faf27987723eaa8fae318c603067e490af51b9e Mon Sep 17 00:00:00 2001 From: Dan Stroot Date: Mon, 17 Feb 2014 10:00:43 -0800 Subject: [PATCH 01/27] Added complete password reset function --- app.js | 6 ++ controllers/forgot.js | 201 +++++++++++++++++++++++++++++++++++ controllers/reset.js | 213 ++++++++++++++++++++++++++++++++++++++ models/User.js | 5 +- package.json | 3 +- views/account/forgot.jade | 28 +++++ views/account/login.jade | 7 +- views/account/reset.jade | 36 +++++++ 8 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 controllers/forgot.js create mode 100644 controllers/reset.js create mode 100644 views/account/forgot.jade create mode 100644 views/account/reset.jade diff --git a/app.js b/app.js index d8e6cdfd5f..cb25e76d0f 100755 --- a/app.js +++ b/app.js @@ -18,6 +18,8 @@ var homeController = require('./controllers/home'); var userController = require('./controllers/user'); var apiController = require('./controllers/api'); var contactController = require('./controllers/contact'); +var forgotController = require('./controllers/forgot'); +var resetController = require('./controllers/reset'); /** * API keys + Passport configuration. @@ -98,6 +100,10 @@ app.get('/', homeController.index); app.get('/login', userController.getLogin); app.post('/login', userController.postLogin); app.get('/logout', userController.logout); +app.get('/forgot', forgotController.getForgot); +app.post('/forgot', forgotController.postForgot); +app.get('/reset/:id/:token', resetController.getReset); +app.post('/reset/:id/:token', resetController.postReset); app.get('/signup', userController.getSignup); app.post('/signup', userController.postSignup); app.get('/contact', contactController.getContact); diff --git a/controllers/forgot.js b/controllers/forgot.js new file mode 100644 index 0000000000..a4b41a102e --- /dev/null +++ b/controllers/forgot.js @@ -0,0 +1,201 @@ +'use strict'; + +/** + * Module dependencies. + */ + +var bcrypt = require('bcrypt-nodejs'); +var crypto = require('crypto'); +var mongoose = require('mongoose'); +var nodemailer = require("nodemailer"); +var User = require('../models/User'); +var secrets = require('../config/secrets'); + +/** + * Forgot Controller + */ + +/** + + The general outline of the best practice is: + + 1) Identify the user is a valid account holder. Use as much information as practical. + - Email Address (*Bare Minimin*) + - Username + - Account Number + - Security Questions + - Etc. + + 2) Create a special one-time (nonce) token, with a expiration period, tied to the person's account. + In this example We will store this in the database on the user's record. + + 3) Send the user a link which contains the route ( /reset/:id/:token/ ) where the + user can change their password. + + 4) When the user clicks the link: + - Lookup the user/nonce token and check expiration. If any issues send a message + to the user: "this link is invalid". + - If all good then continue - render password reset form. + + 5) The user enters their new password (and possibly a second time for verification) + and posts this back. + + 6) Validate the password(s) meet complexity requirements and match. If so, hash the + password and save it to the database. Here we will also clear the reset token. + + 7) Email the user "Success, your password is reset". This is important in case the user + did not initiate the reset! + + 7) Redirect the user. Could be to the login page but since we know the users email and + password we can simply authenticate them and redirect to a logged in location - usually + home page. + +*/ + + +/** + * GET /forgot + * Forgot your password page. + */ + +exports.getForgot = function(req, res) { + if (req.user) return res.redirect('/'); //user already logged in! + res.render('account/forgot', { + }); +}; + +/** + * POST /forgot + * Reset Password. + * @param {string} email + */ + +exports.postForgot = function(req, res) { + + // Begin a workflow + var workflow = new (require('events').EventEmitter)(); + + /** + * Step 1: Is the email valid? + */ + + workflow.on('validate', function() { + + // Check for form errors + req.assert('email', 'Email cannot be blank.').notEmpty(); + req.assert('email', 'Please enter a valid email address.').isEmail(); + var errors = req.validationErrors(); + + if (errors) { + req.flash('errors', errors); + return res.redirect('/forgot'); + } + + // next step + workflow.emit('generateToken'); + }); + + /** + * Step 2: Generate a one-time (nonce) token + */ + + workflow.on('generateToken', function() { + // generate token + crypto.randomBytes(21, function(err, buf) { + var token = buf.toString('hex'); + // hash token + bcrypt.genSalt(10, function(err, salt) { + bcrypt.hash(token, salt, null, function(err, hash) { + // next step + workflow.emit('saveToken', token, hash); + }); + }); + }); + }); + + /** + * Step 3: Save the token and token expiration + */ + + workflow.on('saveToken', function(token, hash) { + // lookup user + User.findOne({ email: req.body.email.toLowerCase() }, function(err, user) { + if (err) { + req.flash('errors', err); + return res.redirect('/forgot'); + } + if (!user) { + // If we didn't find a user associated with that + // email address then just finish the workflow + req.flash('info', { msg: 'If you have an account with that email address then we sent you an email with instructions. Check your email!' }); + return res.redirect('/forgot'); + } + + user.resetPasswordToken = hash; + user.resetPasswordExpires = Date.now() + 10000000; + + // update the user's record with the token + user.save(function(err) { + if (err) { + req.flash('errors', err); + return res.redirect('/forgot'); + } + }); + + // next step + workflow.emit('sendEmail', token, user); + }); + }); + + /** + * Step 4: Send the user an email with a reset link + */ + + workflow.on('sendEmail', function(token, user) { + + // Create a reusable nodemailer transport method (opens a pool of SMTP connections) + var smtpTransport = nodemailer.createTransport("SMTP",{ + service: "Gmail", + auth: { + user: secrets.gmail.user, + pass: secrets.gmail.password + } + // See nodemailer docs for other transports + // https://github.com/andris9/Nodemailer + }); + + console.log('User: ' + secrets.gmail.user); + console.log('Pass: ' + secrets.gmail.password); + + // create email + var mailOptions = { + to: user.profile.name + ' <' + user.email + '>', + from: 'hackathon@starter.com', // TODO parameterize + subject: 'Password Reset Link', + text: 'Hello from hackathon-starter. Your password reset link is:' + '\n\n' + req.protocol +'://'+ req.headers.host +'/reset/'+ user.id +'/'+ token + }; + + // send email + smtpTransport.sendMail(mailOptions, function(err) { + if (err) { + req.flash('errors', { msg: err.message }); + return res.redirect('/forgot'); + } else { + // Message to user + req.flash('info', { msg: 'If you have an account with that email address then we sent you an email with instructions. Check your email!' }); + return res.redirect('/forgot'); + } + }); + + // shut down the connection pool, no more messages + smtpTransport.close(); + + }); + + /** + * Initiate the workflow + */ + + workflow.emit('validate'); + +}; diff --git a/controllers/reset.js b/controllers/reset.js new file mode 100644 index 0000000000..ac5382fb9d --- /dev/null +++ b/controllers/reset.js @@ -0,0 +1,213 @@ +'use strict'; + +/** + * Module Dependencies + */ + +var bcrypt = require('bcrypt-nodejs'); +var mongoose = require('mongoose'); +var nodemailer = require("nodemailer"); +var User = require('../models/User'); +var secrets = require('../config/secrets'); + +/** + * GET /reset/:id/:token + * Reset your password page + */ + +exports.getReset = function(req, res) { + if (req.user) return res.redirect('/'); //user already logged in! + + var conditions = { + _id: req.params.id, + resetPasswordExpires: { $gt: Date.now() } + }; + + // Get the user + User.findOne(conditions, function(err, user) { + if (err) { + req.flash('errors', err); + return res.render('account/reset', { + validToken: false + }); + } + if (!user) { + req.flash('errors', { msg: 'Your reset request is invalid. It may have expired.' }); + return res.render('account/reset', { + validToken: false + }); + } + // Validate the token + bcrypt.compare(req.params.token, user.resetPasswordToken, function(err, isValid) { + if (err) { + req.flash('errors', err); + return res.render('account/reset', { + validToken: false + }); + } + if (!isValid) { + req.flash('errors', { msg: 'Your reset request token is invalid.' }); + return res.render('account/reset', { + validToken: false + }); + } else { + req.flash('success', { msg: 'Token accepted. Reset your password!' }); + return res.render('account/reset', { + validToken: true + }); + } + }); + }); +}; + +/** + * POST /reset/:id/:token + * Process the POST to reset your password + */ + +exports.postReset = function(req, res) { + + // Create a workflow + var workflow = new (require('events').EventEmitter)(); + + /** + * Step 1: Validate the password(s) meet complexity requirements and match. + */ + + workflow.on('validate', function() { + + req.assert('password', 'Password must be at least 4 characters long.').len(4); + req.assert('confirm', 'Passwords must match.').equals(req.body.password); + var errors = req.validationErrors(); + + if (errors) { + req.flash('errors', errors); + return res.render('account/reset', {}); + } + + // next step + workflow.emit('findUser'); + }); + + /** + * Step 2: Lookup the User + * We are doing this again in case the user changed the URL + */ + + workflow.on('findUser', function() { + + var conditions = { + _id: req.params.id, + resetPasswordExpires: { $gt: Date.now() } + }; + + // Get the user + User.findOne(conditions, function(err, user) { + if (err) { + req.flash('errors', err); + return res.render('account/reset', {}); + } + + if (!user) { + req.flash('errors', { msg: 'Your reset request is invalid. It may have expired.' }); + return res.render('account/reset', {}); + } + + // Validate the token + bcrypt.compare(req.params.token, user.resetPasswordToken, function(err, isValid) { + if (err) { + req.flash('errors', err); + return res.render('account/reset', {}); + } + if (!isValid) { + req.flash('errors', { msg: 'Your reset request token is invalid.' }); + return res.render('account/reset', {}); + } + }); + + // next step + workflow.emit('updatePassword', user); + }); + }); + + /** + * Step 3: Update the User's Password and clear the + * clear the reset token + */ + + workflow.on('updatePassword', function(user) { + + user.password = req.body.password; + user.resetPasswordToken = ''; + user.resetPasswordExpires = Date.now(); + + // update the user record + user.save(function(err) { + if (err) { + req.flash('errors', err); + return res.render('account/reset', {}); + } + // Log the user in + req.logIn(user, function(err) { + if (err) { + req.flash('errors', err); + return res.render('account/reset', {}); + } + // next step + workflow.emit('sendEmail', user); + }); + }); + }); + + /** + * Step 4: Send the User an email letting them know thier + * password was changed. This is important in case the + * user did not initiate the reset! + */ + + workflow.on('sendEmail', function(user) { + + // Create a reusable nodemailer transport method (opens a pool of SMTP connections) + var smtpTransport = nodemailer.createTransport("SMTP",{ + service: "Gmail", + auth: { + user: process.env.SMTP_USERNAME || '', + pass: process.env.SMTP_PASSWORD || '' + } + // See nodemailer docs for other transports + // https://github.com/andris9/Nodemailer + }); + + // create email + var mailOptions = { + to: user.profile.name + ' <' + user.email + '>', + from: 'hackathon@starter.com', // TODO parameterize + subject: 'Password Reset Notice', + text: 'This is a courtesy message from hackathon-starter. Your password was just reset. Cheers!' + }; + + // send email + smtpTransport.sendMail(mailOptions, function(err) { + if (err) { + req.flash('errors', { msg: err.message }); + req.flash('info', { msg: 'You are logged in with your new password!' }); + res.redirect('/'); + } else { + // Message to user + req.flash('info', { msg: 'You are logged in with your new password!' }); + res.redirect('/'); + } + }); + + // shut down the connection pool, no more messages + smtpTransport.close(); + + }); + + /** + * Initiate the workflow + */ + + workflow.emit('validate'); + +}; diff --git a/models/User.js b/models/User.js index f21d1596ab..cc0f641a1f 100644 --- a/models/User.js +++ b/models/User.js @@ -18,7 +18,10 @@ var userSchema = new mongoose.Schema({ location: { type: String, default: '' }, website: { type: String, default: '' }, picture: { type: String, default: '' } - } + }, + + resetPasswordToken: { type: String, default: '' }, + resetPasswordExpires: { type: Date } }); /** diff --git a/package.json b/package.json index a7512721b1..6c65f971f8 100755 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "paypal-rest-sdk": "~0.6.4", "connect-mongo": "~0.4.0", "twilio": "~1.5.0", - "validator": "~3.2.1" + "validator": "~3.2.1", + "crypto": "0.0.3" } } diff --git a/views/account/forgot.jade b/views/account/forgot.jade new file mode 100644 index 0000000000..06e34cf47d --- /dev/null +++ b/views/account/forgot.jade @@ -0,0 +1,28 @@ +extends ../layout + +block content + .container + .row + .col-sm-6.col-sm-offset-3 + br + br + form(method='POST') + input(type='hidden', name='_csrf', value=token) + legend Forgot Password + div.form-group + p Enter your email address and we'll send you reset instructions. + label.sr-only(for='email') Enter Your Email: + input.form-control(type='email', name='email', id='email', placeholder='Your Email', autofocus=true, required) + div.form-group + button.btn.btn-primary(type='submit') Reset Password + br + p Or, if you rembered your password + a(href='login') sign in. + +//- Form Notes +//- =========================================== +//- 1) Always add labels! +//- Screen readers will have trouble with your forms if you don't include a label for every input. +//- NOTE: you can hide the labels using the .sr-only class. +//- 2) Use proper HTML5 input types (email, password, date, etc.) This adds some HTML5 validation as +//- well as the correct keyboard on mobile devices. diff --git a/views/account/login.jade b/views/account/login.jade index 8bfb653399..543676282b 100644 --- a/views/account/login.jade +++ b/views/account/login.jade @@ -34,4 +34,9 @@ block content button.btn.btn-primary(type='submit') i.fa.fa-unlock-alt | Login - + hr + p Forgot your + a(href='/forgot') password? + p Or, do you need to + a(href='signup') sign up + | for a #{title} account? \ No newline at end of file diff --git a/views/account/reset.jade b/views/account/reset.jade new file mode 100644 index 0000000000..0169b69963 --- /dev/null +++ b/views/account/reset.jade @@ -0,0 +1,36 @@ +extends ../layout + +block content + .container + .row + .col-sm-6.col-sm-offset-3 + .page-header + h1 Reset Your Password + form(method='POST') + input(type='hidden', name='_csrf', value=token) + .form-group + label.sr-only(for='password') New Password: + input.form-control(type='password', name='password', value='', placeholder='New password', autofocus=true, required) + .form-group + label.sr-only(for='confirm') Confirm Password: + input.form-control(type='password', name='confirm', value='', placeholder='Confirm your new password', required) + .form-group + button.btn.btn-primary.btn-reset(type='submit') Set Password + hr + p Need to try again? + a(href='/forgot') Forgot my password + script. + $(document).ready(function() { + if ( #{validToken} === false ) { + $("input").prop('disabled', true); + $("button").prop('disabled', true); + } + }); + +//- Form Notes +//- =========================================== +//- 1) Always add labels! +//- Screen readers will have trouble with your forms if you don't include a label for every input. +//- NOTE: you can hide the labels using the .sr-only class. +//- 2) Use proper HTML5 input types (email, password, date, etc.) This adds some HTML5 validation as +//- well as the correct keyboard on mobile devices. From de1ee38f8ebd6bcf8c89981e52a5f1c547faf6fe Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Mon, 17 Feb 2014 18:54:46 -0500 Subject: [PATCH 02/27] Swapped Gmail for Sendgrid on Forgot Password Send Email workflow --- controllers/forgot.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/controllers/forgot.js b/controllers/forgot.js index a4b41a102e..e6dba955ed 100644 --- a/controllers/forgot.js +++ b/controllers/forgot.js @@ -154,14 +154,12 @@ exports.postForgot = function(req, res) { workflow.on('sendEmail', function(token, user) { // Create a reusable nodemailer transport method (opens a pool of SMTP connections) - var smtpTransport = nodemailer.createTransport("SMTP",{ - service: "Gmail", - auth: { - user: secrets.gmail.user, - pass: secrets.gmail.password - } - // See nodemailer docs for other transports - // https://github.com/andris9/Nodemailer + var smtpTransport = nodemailer.createTransport('SMTP', { + service: 'SendGrid', + auth: { + user: secrets.sendgrid.user, + pass: secrets.sendgrid.password + } }); console.log('User: ' + secrets.gmail.user); From 4092ef56ce73d0677e931c75c4a2e52d8ca1f9cb Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Mon, 17 Feb 2014 19:33:08 -0500 Subject: [PATCH 03/27] Removed box-shadow on btn-link in default theme --- public/css/themes/default.less | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/css/themes/default.less b/public/css/themes/default.less index 336d85288c..edd3d2af41 100644 --- a/public/css/themes/default.less +++ b/public/css/themes/default.less @@ -32,6 +32,10 @@ background-image: linear-gradient(to bottom, #ffffff 60%, #f8f8f8 100%); } +.btn-link { + box-shadow: none; +} + // Forms // ------------------------- From fcd8773518689607c375e3f830a0978a8d3ddfd4 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Mon, 17 Feb 2014 19:36:06 -0500 Subject: [PATCH 04/27] Updated login template --- views/account/login.jade | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/views/account/login.jade b/views/account/login.jade index 543676282b..d309825708 100644 --- a/views/account/login.jade +++ b/views/account/login.jade @@ -34,9 +34,7 @@ block content button.btn.btn-primary(type='submit') i.fa.fa-unlock-alt | Login - hr - p Forgot your - a(href='/forgot') password? - p Or, do you need to - a(href='signup') sign up - | for a #{title} account? \ No newline at end of file + a.btn.btn-link(href='/forgot') Forgot password? + + p Don't have an account? + a(href='signup') Sign up. From 89a8b72181d6802a9a2e253011e0c349ceb540f3 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Mon, 17 Feb 2014 19:44:51 -0500 Subject: [PATCH 05/27] Updated forgot password and login templates --- views/account/forgot.jade | 37 ++++++++++++------------------------- views/account/login.jade | 2 +- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/views/account/forgot.jade b/views/account/forgot.jade index 06e34cf47d..70029f16b8 100644 --- a/views/account/forgot.jade +++ b/views/account/forgot.jade @@ -1,28 +1,15 @@ extends ../layout block content - .container - .row - .col-sm-6.col-sm-offset-3 - br - br - form(method='POST') - input(type='hidden', name='_csrf', value=token) - legend Forgot Password - div.form-group - p Enter your email address and we'll send you reset instructions. - label.sr-only(for='email') Enter Your Email: - input.form-control(type='email', name='email', id='email', placeholder='Your Email', autofocus=true, required) - div.form-group - button.btn.btn-primary(type='submit') Reset Password - br - p Or, if you rembered your password - a(href='login') sign in. - -//- Form Notes -//- =========================================== -//- 1) Always add labels! -//- Screen readers will have trouble with your forms if you don't include a label for every input. -//- NOTE: you can hide the labels using the .sr-only class. -//- 2) Use proper HTML5 input types (email, password, date, etc.) This adds some HTML5 validation as -//- well as the correct keyboard on mobile devices. + .col-sm-8.col-sm-offset-2 + form(method='POST') + legend Forgot Password + input(type='hidden', name='_csrf', value=token) + .form-group + p Enter your email address below and we will send you password reset instructions. + label.control-label(for='email') Email + input.form-control(type='email', name='email', id='email', placeholder='Email', autofocus=true) + .form-group + button.btn.btn-primary(type='submit') + i.fa.fa-key + | Reset Password diff --git a/views/account/login.jade b/views/account/login.jade index d309825708..91aecaa601 100644 --- a/views/account/login.jade +++ b/views/account/login.jade @@ -3,8 +3,8 @@ extends ../layout block content .col-sm-8.col-sm-offset-2 form(method='POST') - input(type='hidden', name='_csrf', value=token) legend Sign In + input(type='hidden', name='_csrf', value=token) .form-group .btn-group.btn-group-justified if secrets.facebookAuth From 27dab8fbf9e250521b6cd9ee89559ddafc82f4e3 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Mon, 17 Feb 2014 19:45:33 -0500 Subject: [PATCH 06/27] Removed redundant check for empty email address field --- controllers/forgot.js | 79 +++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/controllers/forgot.js b/controllers/forgot.js index e6dba955ed..f4a9b4c0f9 100644 --- a/controllers/forgot.js +++ b/controllers/forgot.js @@ -4,12 +4,12 @@ * Module dependencies. */ -var bcrypt = require('bcrypt-nodejs'); -var crypto = require('crypto'); -var mongoose = require('mongoose'); -var nodemailer = require("nodemailer"); -var User = require('../models/User'); -var secrets = require('../config/secrets'); +var bcrypt = require('bcrypt-nodejs'); +var crypto = require('crypto'); +var mongoose = require('mongoose'); +var nodemailer = require("nodemailer"); +var User = require('../models/User'); +var secrets = require('../config/secrets'); /** * Forgot Controller @@ -17,40 +17,40 @@ var secrets = require('../config/secrets'); /** - The general outline of the best practice is: + The general outline of the best practice is: - 1) Identify the user is a valid account holder. Use as much information as practical. - - Email Address (*Bare Minimin*) - - Username - - Account Number - - Security Questions - - Etc. + 1) Identify the user is a valid account holder. Use as much information as practical. + - Email Address (*Bare Minimin*) + - Username + - Account Number + - Security Questions + - Etc. - 2) Create a special one-time (nonce) token, with a expiration period, tied to the person's account. - In this example We will store this in the database on the user's record. + 2) Create a special one-time (nonce) token, with a expiration period, tied to the person's account. + In this example We will store this in the database on the user's record. - 3) Send the user a link which contains the route ( /reset/:id/:token/ ) where the - user can change their password. + 3) Send the user a link which contains the route ( /reset/:id/:token/ ) where the + user can change their password. - 4) When the user clicks the link: - - Lookup the user/nonce token and check expiration. If any issues send a message - to the user: "this link is invalid". - - If all good then continue - render password reset form. + 4) When the user clicks the link: + - Lookup the user/nonce token and check expiration. If any issues send a message + to the user: "this link is invalid". + - If all good then continue - render password reset form. - 5) The user enters their new password (and possibly a second time for verification) - and posts this back. + 5) The user enters their new password (and possibly a second time for verification) + and posts this back. - 6) Validate the password(s) meet complexity requirements and match. If so, hash the - password and save it to the database. Here we will also clear the reset token. + 6) Validate the password(s) meet complexity requirements and match. If so, hash the + password and save it to the database. Here we will also clear the reset token. - 7) Email the user "Success, your password is reset". This is important in case the user - did not initiate the reset! + 7) Email the user "Success, your password is reset". This is important in case the user + did not initiate the reset! - 7) Redirect the user. Could be to the login page but since we know the users email and - password we can simply authenticate them and redirect to a logged in location - usually - home page. + 7) Redirect the user. Could be to the login page but since we know the users email and + password we can simply authenticate them and redirect to a logged in location - usually + home page. -*/ + */ /** @@ -82,7 +82,6 @@ exports.postForgot = function(req, res) { workflow.on('validate', function() { // Check for form errors - req.assert('email', 'Email cannot be blank.').notEmpty(); req.assert('email', 'Please enter a valid email address.').isEmail(); var errors = req.validationErrors(); @@ -105,10 +104,10 @@ exports.postForgot = function(req, res) { var token = buf.toString('hex'); // hash token bcrypt.genSalt(10, function(err, salt) { - bcrypt.hash(token, salt, null, function(err, hash) { - // next step - workflow.emit('saveToken', token, hash); - }); + bcrypt.hash(token, salt, null, function(err, hash) { + // next step + workflow.emit('saveToken', token, hash); + }); }); }); }); @@ -167,10 +166,10 @@ exports.postForgot = function(req, res) { // create email var mailOptions = { - to: user.profile.name + ' <' + user.email + '>', - from: 'hackathon@starter.com', // TODO parameterize - subject: 'Password Reset Link', - text: 'Hello from hackathon-starter. Your password reset link is:' + '\n\n' + req.protocol +'://'+ req.headers.host +'/reset/'+ user.id +'/'+ token + to: user.profile.name + ' <' + user.email + '>', + from: 'hackathon@starter.com', // TODO parameterize + subject: 'Password Reset Link', + text: 'Hello from hackathon-starter. Your password reset link is:' + '\n\n' + req.protocol + '://' + req.headers.host + '/reset/' + user.id + '/' + token }; // send email From 7e06b6a16154bea8fdba0de1a0042babc11115af Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Mon, 17 Feb 2014 19:46:37 -0500 Subject: [PATCH 07/27] Add title "Forgot Password" to GET /forgot template --- controllers/forgot.js | 1 + 1 file changed, 1 insertion(+) diff --git a/controllers/forgot.js b/controllers/forgot.js index f4a9b4c0f9..11b9210d2e 100644 --- a/controllers/forgot.js +++ b/controllers/forgot.js @@ -61,6 +61,7 @@ var secrets = require('../config/secrets'); exports.getForgot = function(req, res) { if (req.user) return res.redirect('/'); //user already logged in! res.render('account/forgot', { + title: 'Forgot Password' }); }; From 0777294c9884a8aa24f66191681e99732d0dda24 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Mon, 17 Feb 2014 20:45:29 -0500 Subject: [PATCH 08/27] Updated email template text, removed token salting, changed token to base64 (24bit) --- controllers/forgot.js | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/controllers/forgot.js b/controllers/forgot.js index 11b9210d2e..486369fded 100644 --- a/controllers/forgot.js +++ b/controllers/forgot.js @@ -101,15 +101,11 @@ exports.postForgot = function(req, res) { workflow.on('generateToken', function() { // generate token - crypto.randomBytes(21, function(err, buf) { - var token = buf.toString('hex'); - // hash token - bcrypt.genSalt(10, function(err, salt) { - bcrypt.hash(token, salt, null, function(err, hash) { - // next step - workflow.emit('saveToken', token, hash); - }); - }); + crypto.randomBytes(24, function(err, buf) { + if (err) return next(err); + var token = buf.toString('base64'); + console.log(token); + workflow.emit('saveToken', token) }); }); @@ -117,7 +113,7 @@ exports.postForgot = function(req, res) { * Step 3: Save the token and token expiration */ - workflow.on('saveToken', function(token, hash) { + workflow.on('saveToken', function(token) { // lookup user User.findOne({ email: req.body.email.toLowerCase() }, function(err, user) { if (err) { @@ -131,7 +127,7 @@ exports.postForgot = function(req, res) { return res.redirect('/forgot'); } - user.resetPasswordToken = hash; + user.resetPasswordToken = token; user.resetPasswordExpires = Date.now() + 10000000; // update the user's record with the token @@ -152,8 +148,6 @@ exports.postForgot = function(req, res) { */ workflow.on('sendEmail', function(token, user) { - - // Create a reusable nodemailer transport method (opens a pool of SMTP connections) var smtpTransport = nodemailer.createTransport('SMTP', { service: 'SendGrid', auth: { @@ -162,15 +156,14 @@ exports.postForgot = function(req, res) { } }); - console.log('User: ' + secrets.gmail.user); - console.log('Pass: ' + secrets.gmail.password); - - // create email var mailOptions = { to: user.profile.name + ' <' + user.email + '>', - from: 'hackathon@starter.com', // TODO parameterize - subject: 'Password Reset Link', - text: 'Hello from hackathon-starter. Your password reset link is:' + '\n\n' + req.protocol + '://' + req.headers.host + '/reset/' + user.id + '/' + token + from: 'hackathon@starter.com', + subject: 'Hackathon Starter Password Reset', + text: 'You are receiving this because you (or someone else) have requested the reset of the password for your account.\n\n' + + 'Please click on the following link, or paste this into your browser to complete the process:\n\n' + + 'http://' + req.headers.host + '/reset/' + token + '\n\n' + + 'If you did not request this, please ignore this email and your password will remain unchanged.\n' }; // send email From bde061debf276dcf0d2c06457b753d5643924d49 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Mon, 17 Feb 2014 20:46:21 -0500 Subject: [PATCH 09/27] Removed user _id from reset route. Use only token value. It's random enough that you don't need to include user id as well. --- app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index cb25e76d0f..cf07fc02c7 100755 --- a/app.js +++ b/app.js @@ -102,8 +102,8 @@ app.post('/login', userController.postLogin); app.get('/logout', userController.logout); app.get('/forgot', forgotController.getForgot); app.post('/forgot', forgotController.postForgot); -app.get('/reset/:id/:token', resetController.getReset); -app.post('/reset/:id/:token', resetController.postReset); +app.get('/reset/:token', resetController.getReset); +app.post('/reset/:token', resetController.postReset); app.get('/signup', userController.getSignup); app.post('/signup', userController.postSignup); app.get('/contact', contactController.getContact); From 58c3db89edfd9f049962de0276935262240d6d43 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 01:16:34 -0500 Subject: [PATCH 10/27] Updated expiration of password token to 1hr, updated flash message when email is sent with password recovery instructions. --- controllers/forgot.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/controllers/forgot.js b/controllers/forgot.js index 486369fded..fae56c75d5 100644 --- a/controllers/forgot.js +++ b/controllers/forgot.js @@ -128,7 +128,7 @@ exports.postForgot = function(req, res) { } user.resetPasswordToken = token; - user.resetPasswordExpires = Date.now() + 10000000; + user.resetPasswordExpires = Date.now() + 3600000; // 1 hour // update the user's record with the token user.save(function(err) { @@ -166,14 +166,13 @@ exports.postForgot = function(req, res) { 'If you did not request this, please ignore this email and your password will remain unchanged.\n' }; - // send email smtpTransport.sendMail(mailOptions, function(err) { if (err) { req.flash('errors', { msg: err.message }); return res.redirect('/forgot'); } else { // Message to user - req.flash('info', { msg: 'If you have an account with that email address then we sent you an email with instructions. Check your email!' }); + req.flash('info', { msg: 'We have sent an email to ' + user.email + ' for further instructions.' }); return res.redirect('/forgot'); } }); From 6549966a160abdf76c43c4913f79447d4f2b15e8 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 01:40:03 -0500 Subject: [PATCH 11/27] Update error flash message, redirect to /forgot if no reset token is found or if it has expired --- controllers/reset.js | 52 +++++++++++++++++++------------------------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/controllers/reset.js b/controllers/reset.js index ac5382fb9d..e04fe9fc35 100644 --- a/controllers/reset.js +++ b/controllers/reset.js @@ -1,22 +1,16 @@ -'use strict'; +var bcrypt = require('bcrypt-nodejs'); +var nodemailer = require('nodemailer'); +var User = require('../models/User'); /** - * Module Dependencies - */ - -var bcrypt = require('bcrypt-nodejs'); -var mongoose = require('mongoose'); -var nodemailer = require("nodemailer"); -var User = require('../models/User'); -var secrets = require('../config/secrets'); - -/** - * GET /reset/:id/:token - * Reset your password page + * GET /reset/:token + * Reset Password page. */ exports.getReset = function(req, res) { - if (req.user) return res.redirect('/'); //user already logged in! + if (req.isAuthenticated()) { + return res.redirect('/'); + } var conditions = { _id: req.params.id, @@ -32,10 +26,8 @@ exports.getReset = function(req, res) { }); } if (!user) { - req.flash('errors', { msg: 'Your reset request is invalid. It may have expired.' }); - return res.render('account/reset', { - validToken: false - }); + req.flash('errors', { msg: 'Password reset token is invalid or has expired.' }); + return res.redirect('/forgot'); } // Validate the token bcrypt.compare(req.params.token, user.resetPasswordToken, function(err, isValid) { @@ -168,22 +160,22 @@ exports.postReset = function(req, res) { workflow.on('sendEmail', function(user) { // Create a reusable nodemailer transport method (opens a pool of SMTP connections) - var smtpTransport = nodemailer.createTransport("SMTP",{ - service: "Gmail", - auth: { - user: process.env.SMTP_USERNAME || '', - pass: process.env.SMTP_PASSWORD || '' - } - // See nodemailer docs for other transports - // https://github.com/andris9/Nodemailer + var smtpTransport = nodemailer.createTransport("SMTP", { + service: "Gmail", + auth: { + user: process.env.SMTP_USERNAME || '', + pass: process.env.SMTP_PASSWORD || '' + } + // See nodemailer docs for other transports + // https://github.com/andris9/Nodemailer }); // create email var mailOptions = { - to: user.profile.name + ' <' + user.email + '>', - from: 'hackathon@starter.com', // TODO parameterize - subject: 'Password Reset Notice', - text: 'This is a courtesy message from hackathon-starter. Your password was just reset. Cheers!' + to: user.profile.name + ' <' + user.email + '>', + from: 'hackathon@starter.com', // TODO parameterize + subject: 'Password Reset Notice', + text: 'This is a courtesy message from hackathon-starter. Your password was just reset. Cheers!' }; // send email From 76a73943f46b3a48e2b6dd0b4c2c58994fb1e47c Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 02:09:51 -0500 Subject: [PATCH 12/27] Converted workflow/eventemitter code to async.waterfall --- controllers/forgot.js | 168 +++++++++++++++--------------------------- 1 file changed, 58 insertions(+), 110 deletions(-) diff --git a/controllers/forgot.js b/controllers/forgot.js index fae56c75d5..da3260740e 100644 --- a/controllers/forgot.js +++ b/controllers/forgot.js @@ -3,7 +3,7 @@ /** * Module dependencies. */ - +var async = require('async'); var bcrypt = require('bcrypt-nodejs'); var crypto = require('crypto'); var mongoose = require('mongoose'); @@ -72,120 +72,68 @@ exports.getForgot = function(req, res) { */ exports.postForgot = function(req, res) { + req.assert('email', 'Please enter a valid email address.').isEmail(); - // Begin a workflow - var workflow = new (require('events').EventEmitter)(); + var errors = req.validationErrors(); - /** - * Step 1: Is the email valid? - */ + if (errors) { + req.flash('errors', errors); + return res.redirect('/forgot'); + } - workflow.on('validate', function() { - - // Check for form errors - req.assert('email', 'Please enter a valid email address.').isEmail(); - var errors = req.validationErrors(); - - if (errors) { - req.flash('errors', errors); - return res.redirect('/forgot'); - } - - // next step - workflow.emit('generateToken'); - }); - - /** - * Step 2: Generate a one-time (nonce) token - */ - - workflow.on('generateToken', function() { - // generate token - crypto.randomBytes(24, function(err, buf) { - if (err) return next(err); - var token = buf.toString('base64'); - console.log(token); - workflow.emit('saveToken', token) - }); - }); - - /** - * Step 3: Save the token and token expiration - */ - - workflow.on('saveToken', function(token) { - // lookup user - User.findOne({ email: req.body.email.toLowerCase() }, function(err, user) { - if (err) { - req.flash('errors', err); - return res.redirect('/forgot'); - } - if (!user) { - // If we didn't find a user associated with that - // email address then just finish the workflow - req.flash('info', { msg: 'If you have an account with that email address then we sent you an email with instructions. Check your email!' }); - return res.redirect('/forgot'); - } - - user.resetPasswordToken = token; - user.resetPasswordExpires = Date.now() + 3600000; // 1 hour - - // update the user's record with the token - user.save(function(err) { - if (err) { - req.flash('errors', err); + async.waterfall([ + function(done) { + /** + * Generate a one-time token. + */ + crypto.randomBytes(32, function(err, buf) { + var token = buf.toString('base64'); + done(err, token); + }); + }, + function(token, done) { + /** + * Save the token and token expiration. + */ + User.findOne({ email: req.body.email.toLowerCase() }, function(err, user) { + if (!user) { + req.flash('errors', { msg: 'No account with that email address exists.' }); return res.redirect('/forgot'); } + + user.resetPasswordToken = token; + user.resetPasswordExpires = Date.now() + 3600000; // 1 hour + + user.save(function(err) { + done(err, token, user); + }); }); - - // next step - workflow.emit('sendEmail', token, user); - }); - }); - - /** - * Step 4: Send the user an email with a reset link - */ - - workflow.on('sendEmail', function(token, user) { - var smtpTransport = nodemailer.createTransport('SMTP', { - service: 'SendGrid', - auth: { - user: secrets.sendgrid.user, - pass: secrets.sendgrid.password - } - }); - - var mailOptions = { - to: user.profile.name + ' <' + user.email + '>', - from: 'hackathon@starter.com', - subject: 'Hackathon Starter Password Reset', - text: 'You are receiving this because you (or someone else) have requested the reset of the password for your account.\n\n' + - 'Please click on the following link, or paste this into your browser to complete the process:\n\n' + - 'http://' + req.headers.host + '/reset/' + token + '\n\n' + - 'If you did not request this, please ignore this email and your password will remain unchanged.\n' - }; - - smtpTransport.sendMail(mailOptions, function(err) { - if (err) { - req.flash('errors', { msg: err.message }); - return res.redirect('/forgot'); - } else { - // Message to user + }, + function(token, user, done) { + /** + * Send the user an email with a reset link. + */ + var smtpTransport = nodemailer.createTransport('SMTP', { + service: 'SendGrid', + auth: { + user: secrets.sendgrid.user, + pass: secrets.sendgrid.password + } + }); + var mailOptions = { + to: user.profile.name + ' <' + user.email + '>', + from: 'hackathon@starter.com', + subject: 'Hackathon Starter Password Reset', + text: 'You are receiving this because you (or someone else) have requested the reset of the password for your account.\n\n' + + 'Please click on the following link, or paste this into your browser to complete the process:\n\n' + + 'http://' + req.headers.host + '/reset/' + token + '\n\n' + + 'If you did not request this, please ignore this email and your password will remain unchanged.\n' + }; + smtpTransport.sendMail(mailOptions, function(err) { req.flash('info', { msg: 'We have sent an email to ' + user.email + ' for further instructions.' }); - return res.redirect('/forgot'); - } - }); - - // shut down the connection pool, no more messages - smtpTransport.close(); - - }); - - /** - * Initiate the workflow - */ - - workflow.emit('validate'); - + done(err, 'done'); + res.redirect('/forgot'); + }); + } + ]); }; From ffb2c7b798c6a70971b3fa09079ddbc58e773551 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 02:15:03 -0500 Subject: [PATCH 13/27] Refactor Forgot controller --- controllers/forgot.js | 66 ++++--------------------------------------- 1 file changed, 5 insertions(+), 61 deletions(-) diff --git a/controllers/forgot.js b/controllers/forgot.js index da3260740e..03dcffe99f 100644 --- a/controllers/forgot.js +++ b/controllers/forgot.js @@ -1,65 +1,18 @@ -'use strict'; - -/** - * Module dependencies. - */ var async = require('async'); -var bcrypt = require('bcrypt-nodejs'); var crypto = require('crypto'); -var mongoose = require('mongoose'); var nodemailer = require("nodemailer"); var User = require('../models/User'); var secrets = require('../config/secrets'); -/** - * Forgot Controller - */ - -/** - - The general outline of the best practice is: - - 1) Identify the user is a valid account holder. Use as much information as practical. - - Email Address (*Bare Minimin*) - - Username - - Account Number - - Security Questions - - Etc. - - 2) Create a special one-time (nonce) token, with a expiration period, tied to the person's account. - In this example We will store this in the database on the user's record. - - 3) Send the user a link which contains the route ( /reset/:id/:token/ ) where the - user can change their password. - - 4) When the user clicks the link: - - Lookup the user/nonce token and check expiration. If any issues send a message - to the user: "this link is invalid". - - If all good then continue - render password reset form. - - 5) The user enters their new password (and possibly a second time for verification) - and posts this back. - - 6) Validate the password(s) meet complexity requirements and match. If so, hash the - password and save it to the database. Here we will also clear the reset token. - - 7) Email the user "Success, your password is reset". This is important in case the user - did not initiate the reset! - - 7) Redirect the user. Could be to the login page but since we know the users email and - password we can simply authenticate them and redirect to a logged in location - usually - home page. - - */ - - /** * GET /forgot - * Forgot your password page. + * Forgot Password page. */ exports.getForgot = function(req, res) { - if (req.user) return res.redirect('/'); //user already logged in! + if (req.isAuthenticated()) { + return res.redirect('/'); + } res.render('account/forgot', { title: 'Forgot Password' }); @@ -68,7 +21,7 @@ exports.getForgot = function(req, res) { /** * POST /forgot * Reset Password. - * @param {string} email + * @param email */ exports.postForgot = function(req, res) { @@ -83,18 +36,12 @@ exports.postForgot = function(req, res) { async.waterfall([ function(done) { - /** - * Generate a one-time token. - */ crypto.randomBytes(32, function(err, buf) { var token = buf.toString('base64'); done(err, token); }); }, function(token, done) { - /** - * Save the token and token expiration. - */ User.findOne({ email: req.body.email.toLowerCase() }, function(err, user) { if (!user) { req.flash('errors', { msg: 'No account with that email address exists.' }); @@ -110,9 +57,6 @@ exports.postForgot = function(req, res) { }); }, function(token, user, done) { - /** - * Send the user an email with a reset link. - */ var smtpTransport = nodemailer.createTransport('SMTP', { service: 'SendGrid', auth: { From 71c5d31521d09301babd468b5ded35956e8db427 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 02:20:56 -0500 Subject: [PATCH 14/27] Update POST /forgot description. --- controllers/forgot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/forgot.js b/controllers/forgot.js index 03dcffe99f..fc7ee2b76a 100644 --- a/controllers/forgot.js +++ b/controllers/forgot.js @@ -20,7 +20,7 @@ exports.getForgot = function(req, res) { /** * POST /forgot - * Reset Password. + * Create a random token, then the send user an email with a reset link. * @param email */ From 43e2afd6078e6d0ca89128543d5b1582c38b411c Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 02:33:57 -0500 Subject: [PATCH 15/27] Update contributing section --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59f9ae785a..cc76d78323 100644 --- a/README.md +++ b/README.md @@ -955,7 +955,7 @@ TODO Contributing ------------ -If something is unclear, confusing, or needs to be refactored, please let me know. Pull requests are always welcome, but due to the opinionated nature of this project, I cannot accept every pull request. Please open an issue before submitting a pull request. This project uses [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript) with a few exceptions. +If something is unclear, confusing, or needs to be refactored, please let me know. Pull requests are always welcome, but due to the opinionated nature of this project, I cannot accept every pull request. Please open an issue before submitting a pull request. This project uses [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript) with a few minor exceptions. If you are submitting a pull request that involves Jade templates, please make sure you are using *spaces*, not tabs. License ------- From 597f137a2b19a1cede33b981a518ca1df40a71cb Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 02:38:31 -0500 Subject: [PATCH 16/27] Update flash message on successful forgot password request --- controllers/forgot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/forgot.js b/controllers/forgot.js index fc7ee2b76a..5e48af19eb 100644 --- a/controllers/forgot.js +++ b/controllers/forgot.js @@ -74,7 +74,7 @@ exports.postForgot = function(req, res) { 'If you did not request this, please ignore this email and your password will remain unchanged.\n' }; smtpTransport.sendMail(mailOptions, function(err) { - req.flash('info', { msg: 'We have sent an email to ' + user.email + ' for further instructions.' }); + req.flash('info', { msg: 'An e-mail has been sent to ' + user.email + ' with further instructions.' }); done(err, 'done'); res.redirect('/forgot'); }); From b29b0c79658bd844e1776493cd3f811e5392c98a Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 02:57:57 -0500 Subject: [PATCH 17/27] Updated schema's default values for password token and expires fields --- models/User.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models/User.js b/models/User.js index cc0f641a1f..4a6f41dea9 100644 --- a/models/User.js +++ b/models/User.js @@ -20,8 +20,8 @@ var userSchema = new mongoose.Schema({ picture: { type: String, default: '' } }, - resetPasswordToken: { type: String, default: '' }, - resetPasswordExpires: { type: Date } + resetPasswordToken: String, + resetPasswordExpires: Date }); /** From ac61a33867cbfa57ec9d48ff664dc1bf4bfdcd06 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 02:58:16 -0500 Subject: [PATCH 18/27] Cleaned up and refactored reset password template --- views/account/reset.jade | 45 +++++++++++----------------------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/views/account/reset.jade b/views/account/reset.jade index 0169b69963..fe9677e72c 100644 --- a/views/account/reset.jade +++ b/views/account/reset.jade @@ -1,36 +1,15 @@ extends ../layout block content - .container - .row - .col-sm-6.col-sm-offset-3 - .page-header - h1 Reset Your Password - form(method='POST') - input(type='hidden', name='_csrf', value=token) - .form-group - label.sr-only(for='password') New Password: - input.form-control(type='password', name='password', value='', placeholder='New password', autofocus=true, required) - .form-group - label.sr-only(for='confirm') Confirm Password: - input.form-control(type='password', name='confirm', value='', placeholder='Confirm your new password', required) - .form-group - button.btn.btn-primary.btn-reset(type='submit') Set Password - hr - p Need to try again? - a(href='/forgot') Forgot my password - script. - $(document).ready(function() { - if ( #{validToken} === false ) { - $("input").prop('disabled', true); - $("button").prop('disabled', true); - } - }); - -//- Form Notes -//- =========================================== -//- 1) Always add labels! -//- Screen readers will have trouble with your forms if you don't include a label for every input. -//- NOTE: you can hide the labels using the .sr-only class. -//- 2) Use proper HTML5 input types (email, password, date, etc.) This adds some HTML5 validation as -//- well as the correct keyboard on mobile devices. + .col-sm-6.col-sm-offset-3 + form(method='POST') + legend Reset Password + input(type='hidden', name='_csrf', value=token) + .form-group + label(for='password') New Password + input.form-control(type='password', name='password', value='', placeholder='New password', autofocus=true) + .form-group + label(for='confirm') Confirm Password + input.form-control(type='password', name='confirm', value='', placeholder='Confirm password') + .form-group + button.btn.btn-primary.btn-reset(type='submit') Update Password From 1a12c07810dd68b5018f0a9b6628a82058310620 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 03:10:24 -0500 Subject: [PATCH 19/27] Updated reset password template --- views/account/reset.jade | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/views/account/reset.jade b/views/account/reset.jade index fe9677e72c..becf6b0ee4 100644 --- a/views/account/reset.jade +++ b/views/account/reset.jade @@ -1,7 +1,7 @@ extends ../layout block content - .col-sm-6.col-sm-offset-3 + .col-sm-8.col-sm-offset-2 form(method='POST') legend Reset Password input(type='hidden', name='_csrf', value=token) @@ -12,4 +12,6 @@ block content label(for='confirm') Confirm Password input.form-control(type='password', name='confirm', value='', placeholder='Confirm password') .form-group - button.btn.btn-primary.btn-reset(type='submit') Update Password + button.btn.btn-primary.btn-reset(type='submit') + i.fa.fa-keyboard-o + | Update Password From 85ab327432dba23ec8cdf78ef0437bdc1f2e8543 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 03:10:46 -0500 Subject: [PATCH 20/27] Renamed forgot password link --- views/account/login.jade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/account/login.jade b/views/account/login.jade index 91aecaa601..fe8bc8c4c1 100644 --- a/views/account/login.jade +++ b/views/account/login.jade @@ -34,7 +34,7 @@ block content button.btn.btn-primary(type='submit') i.fa.fa-unlock-alt | Login - a.btn.btn-link(href='/forgot') Forgot password? + a.btn.btn-link(href='/forgot') Forgot your password? p Don't have an account? a(href='signup') Sign up. From 6d3bdaeaea164b1c46734286bcdd96fb32b777a3 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 03:13:00 -0500 Subject: [PATCH 21/27] Added callback to async.waterfall for error handling via express middleware --- controllers/forgot.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/controllers/forgot.js b/controllers/forgot.js index 5e48af19eb..a66095c13d 100644 --- a/controllers/forgot.js +++ b/controllers/forgot.js @@ -24,7 +24,7 @@ exports.getForgot = function(req, res) { * @param email */ -exports.postForgot = function(req, res) { +exports.postForgot = function(req, res, next) { req.assert('email', 'Please enter a valid email address.').isEmail(); var errors = req.validationErrors(); @@ -76,8 +76,10 @@ exports.postForgot = function(req, res) { smtpTransport.sendMail(mailOptions, function(err) { req.flash('info', { msg: 'An e-mail has been sent to ' + user.email + ' with further instructions.' }); done(err, 'done'); - res.redirect('/forgot'); }); } - ]); + ], function(err) { + if (err) return next(err); + res.redirect('/forgot'); + }); }; From 4d434aef3c7806a811cf7bfc658be0e2673555fe Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 03:21:05 -0500 Subject: [PATCH 22/27] Converted reset controller from eventemitter to async.waterfall. --- controllers/reset.js | 243 ++++++++++++------------------------------- 1 file changed, 66 insertions(+), 177 deletions(-) diff --git a/controllers/reset.js b/controllers/reset.js index e04fe9fc35..7faa9ad889 100644 --- a/controllers/reset.js +++ b/controllers/reset.js @@ -1,3 +1,4 @@ +var async = require('async'); var bcrypt = require('bcrypt-nodejs'); var nodemailer = require('nodemailer'); var User = require('../models/User'); @@ -12,194 +13,82 @@ exports.getReset = function(req, res) { return res.redirect('/'); } - var conditions = { - _id: req.params.id, - resetPasswordExpires: { $gt: Date.now() } - }; - - // Get the user - User.findOne(conditions, function(err, user) { - if (err) { - req.flash('errors', err); - return res.render('account/reset', { - validToken: false + User + .where('resetPasswordToken', req.params.token) + .where('resetPasswordExpires').gt(Date.now()) + .exec(function(err, user) { + if (!user) { + req.flash('errors', { msg: 'Password reset token is invalid or has expired.' }); + return res.redirect('/forgot'); + } + res.render('account/reset', { + title: 'Password Reset' }); - } - if (!user) { - req.flash('errors', { msg: 'Password reset token is invalid or has expired.' }); - return res.redirect('/forgot'); - } - // Validate the token - bcrypt.compare(req.params.token, user.resetPasswordToken, function(err, isValid) { - if (err) { - req.flash('errors', err); - return res.render('account/reset', { - validToken: false - }); - } - if (!isValid) { - req.flash('errors', { msg: 'Your reset request token is invalid.' }); - return res.render('account/reset', { - validToken: false - }); - } else { - req.flash('success', { msg: 'Token accepted. Reset your password!' }); - return res.render('account/reset', { - validToken: true - }); - } }); - }); }; /** - * POST /reset/:id/:token - * Process the POST to reset your password + * POST /reset/:token + * Process the reset password request. */ -exports.postReset = function(req, res) { +exports.postReset = function(req, res, next) { + req.assert('password', 'Password must be at least 4 characters long.').len(4); + req.assert('confirm', 'Passwords must match.').equals(req.body.password); - // Create a workflow - var workflow = new (require('events').EventEmitter)(); + var errors = req.validationErrors(); - /** - * Step 1: Validate the password(s) meet complexity requirements and match. - */ + if (errors) { + req.flash('errors', errors); + return res.redirect('back'); + } - workflow.on('validate', function() { + async.waterfall([ + function(done) { + User + .where('resetPasswordToken', req.params.token) + .where('resetPasswordExpires').gt(Date.now()) + .exec(function(err, user) { + if (!user) { + req.flash('errors', { msg: 'Password reset request is invalid. It may have expired.' }); + return res.redirect('back'); + } + done(err, user); + }); + }, + function(user, done) { + user.password = req.body.password; + user.resetPasswordToken = undefined; + user.resetPasswordExpires = undefined; - req.assert('password', 'Password must be at least 4 characters long.').len(4); - req.assert('confirm', 'Passwords must match.').equals(req.body.password); - var errors = req.validationErrors(); - - if (errors) { - req.flash('errors', errors); - return res.render('account/reset', {}); + user.save(function(err) { + if (err) return next(err); + req.logIn(user, function(err) { + done(err, user); + }); + }); + }, + function(user, done) { + var smtpTransport = nodemailer.createTransport('SMTP', { + service: 'SendGrid', + auth: { + user: secrets.sendgrid.user, + pass: secrets.sendgrid.password + } + }); + var mailOptions = { + to: user.profile.name + ' <' + user.email + '>', + from: 'hackathon@starter.com', + subject: 'Your Hackathon Starter password has been changed', + text: 'Hello,\n\n' + + 'This is a confirmation that the password for your account ' + user.email + ' has just been changed.\n' + }; + smtpTransport.sendMail(mailOptions, function(err) { + done(err); + }); } - - // next step - workflow.emit('findUser'); + ], function(err) { + if (err) return next(err); + res.redirect('/'); }); - - /** - * Step 2: Lookup the User - * We are doing this again in case the user changed the URL - */ - - workflow.on('findUser', function() { - - var conditions = { - _id: req.params.id, - resetPasswordExpires: { $gt: Date.now() } - }; - - // Get the user - User.findOne(conditions, function(err, user) { - if (err) { - req.flash('errors', err); - return res.render('account/reset', {}); - } - - if (!user) { - req.flash('errors', { msg: 'Your reset request is invalid. It may have expired.' }); - return res.render('account/reset', {}); - } - - // Validate the token - bcrypt.compare(req.params.token, user.resetPasswordToken, function(err, isValid) { - if (err) { - req.flash('errors', err); - return res.render('account/reset', {}); - } - if (!isValid) { - req.flash('errors', { msg: 'Your reset request token is invalid.' }); - return res.render('account/reset', {}); - } - }); - - // next step - workflow.emit('updatePassword', user); - }); - }); - - /** - * Step 3: Update the User's Password and clear the - * clear the reset token - */ - - workflow.on('updatePassword', function(user) { - - user.password = req.body.password; - user.resetPasswordToken = ''; - user.resetPasswordExpires = Date.now(); - - // update the user record - user.save(function(err) { - if (err) { - req.flash('errors', err); - return res.render('account/reset', {}); - } - // Log the user in - req.logIn(user, function(err) { - if (err) { - req.flash('errors', err); - return res.render('account/reset', {}); - } - // next step - workflow.emit('sendEmail', user); - }); - }); - }); - - /** - * Step 4: Send the User an email letting them know thier - * password was changed. This is important in case the - * user did not initiate the reset! - */ - - workflow.on('sendEmail', function(user) { - - // Create a reusable nodemailer transport method (opens a pool of SMTP connections) - var smtpTransport = nodemailer.createTransport("SMTP", { - service: "Gmail", - auth: { - user: process.env.SMTP_USERNAME || '', - pass: process.env.SMTP_PASSWORD || '' - } - // See nodemailer docs for other transports - // https://github.com/andris9/Nodemailer - }); - - // create email - var mailOptions = { - to: user.profile.name + ' <' + user.email + '>', - from: 'hackathon@starter.com', // TODO parameterize - subject: 'Password Reset Notice', - text: 'This is a courtesy message from hackathon-starter. Your password was just reset. Cheers!' - }; - - // send email - smtpTransport.sendMail(mailOptions, function(err) { - if (err) { - req.flash('errors', { msg: err.message }); - req.flash('info', { msg: 'You are logged in with your new password!' }); - res.redirect('/'); - } else { - // Message to user - req.flash('info', { msg: 'You are logged in with your new password!' }); - res.redirect('/'); - } - }); - - // shut down the connection pool, no more messages - smtpTransport.close(); - - }); - - /** - * Initiate the workflow - */ - - workflow.emit('validate'); - }; From d24045ec497119f5b5dbd822c17aff651986b53e Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 03:29:25 -0500 Subject: [PATCH 23/27] Merged first and second waterfall steps into one, added var secrets = require('../config/secrets');, and mongoose query now returns a user object instead of an array --- controllers/reset.js | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/controllers/reset.js b/controllers/reset.js index 7faa9ad889..81d3beede7 100644 --- a/controllers/reset.js +++ b/controllers/reset.js @@ -14,7 +14,7 @@ exports.getReset = function(req, res) { } User - .where('resetPasswordToken', req.params.token) + .findOne({ resetPasswordToken: req.params.token }) .where('resetPasswordExpires').gt(Date.now()) .exec(function(err, user) { if (!user) { @@ -46,27 +46,25 @@ exports.postReset = function(req, res, next) { async.waterfall([ function(done) { User - .where('resetPasswordToken', req.params.token) + .findOne({ resetPasswordToken: req.params.token }) .where('resetPasswordExpires').gt(Date.now()) .exec(function(err, user) { if (!user) { - req.flash('errors', { msg: 'Password reset request is invalid. It may have expired.' }); + req.flash('errors', { msg: 'Password reset token is invalid or has expired.' }); return res.redirect('back'); } - done(err, user); - }); - }, - function(user, done) { - user.password = req.body.password; - user.resetPasswordToken = undefined; - user.resetPasswordExpires = undefined; - user.save(function(err) { - if (err) return next(err); - req.logIn(user, function(err) { - done(err, user); + user.password = req.body.password; + user.resetPasswordToken = undefined; + user.resetPasswordExpires = undefined; + + user.save(function(err) { + if (err) return next(err); + req.logIn(user, function(err) { + done(err, user); + }); + }); }); - }); }, function(user, done) { var smtpTransport = nodemailer.createTransport('SMTP', { From b7b74e70b3cae47ba81fd49148ae02fd0b4ba2d2 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 03:33:32 -0500 Subject: [PATCH 24/27] Forgot password token changed to hex instead of base64 to avoid having slashes in the url --- controllers/forgot.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controllers/forgot.js b/controllers/forgot.js index a66095c13d..f0244097f4 100644 --- a/controllers/forgot.js +++ b/controllers/forgot.js @@ -36,8 +36,8 @@ exports.postForgot = function(req, res, next) { async.waterfall([ function(done) { - crypto.randomBytes(32, function(err, buf) { - var token = buf.toString('base64'); + crypto.randomBytes(20, function(err, buf) { + var token = buf.toString('hex'); done(err, token); }); }, From 8aeae3f2549e3945f93ecdc0d31c5b1887e45b44 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 03:46:03 -0500 Subject: [PATCH 25/27] Add success flash notification on successful password reset --- controllers/reset.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/controllers/reset.js b/controllers/reset.js index 81d3beede7..88d2cfd1cd 100644 --- a/controllers/reset.js +++ b/controllers/reset.js @@ -1,7 +1,7 @@ var async = require('async'); -var bcrypt = require('bcrypt-nodejs'); var nodemailer = require('nodemailer'); var User = require('../models/User'); +var secrets = require('../config/secrets'); /** * GET /reset/:token @@ -82,6 +82,7 @@ exports.postReset = function(req, res, next) { 'This is a confirmation that the password for your account ' + user.email + ' has just been changed.\n' }; smtpTransport.sendMail(mailOptions, function(err) { + req.flash('success', { msg: 'Success! Your password has been changed.' }); done(err); }); } From b0daedd3a610c71ea5dfc6ac1dc740938151d317 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 03:49:31 -0500 Subject: [PATCH 26/27] Updated login template --- views/account/login.jade | 3 --- 1 file changed, 3 deletions(-) diff --git a/views/account/login.jade b/views/account/login.jade index fe8bc8c4c1..2eeee1ceb8 100644 --- a/views/account/login.jade +++ b/views/account/login.jade @@ -35,6 +35,3 @@ block content i.fa.fa-unlock-alt | Login a.btn.btn-link(href='/forgot') Forgot your password? - - p Don't have an account? - a(href='signup') Sign up. From e23919c4eb6f4b498b34bbb1ce96ecbdb69ca698 Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Tue, 18 Feb 2014 04:05:46 -0500 Subject: [PATCH 27/27] Added comments to User model on instance methods and mongoose middleware. --- models/User.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/models/User.js b/models/User.js index 4a6f41dea9..ef1a3d8683 100644 --- a/models/User.js +++ b/models/User.js @@ -26,15 +26,15 @@ var userSchema = new mongoose.Schema({ /** * Hash the password for security. + * "Pre" is a Mongoose middleware that executes before each user.save() call. */ userSchema.pre('save', function(next) { var user = this; - var SALT_FACTOR = 5; if (!user.isModified('password')) return next(); - bcrypt.genSalt(SALT_FACTOR, function(err, salt) { + bcrypt.genSalt(5, function(err, salt) { if (err) return next(err); bcrypt.hash(user.password, salt, null, function(err, hash) { @@ -45,6 +45,11 @@ userSchema.pre('save', function(next) { }); }); +/** + * Validate user's password. + * Used by Passport-Local Strategy for password validation. + */ + userSchema.methods.comparePassword = function(candidatePassword, cb) { bcrypt.compare(candidatePassword, this.password, function(err, isMatch) { if (err) return cb(err); @@ -53,7 +58,8 @@ userSchema.methods.comparePassword = function(candidatePassword, cb) { }; /** - * Get a URL to a user's Gravatar email. + * Get URL to a user's gravatar. + * Used in Navbar and Account Management page. */ userSchema.methods.gravatar = function(size, defaults) {