diff --git a/app.js b/app.js index d8e6cdfd5f4..cb25e76d0f1 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 00000000000..a4b41a102ef --- /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 00000000000..ac5382fb9d1 --- /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 f21d1596ab1..cc0f641a1f7 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 a7512721b1f..6c65f971f8c 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 00000000000..06e34cf47da --- /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 8bfb6533991..543676282b1 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 00000000000..0169b699637 --- /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.