Merge branch 'dstroot-reset'

* dstroot-reset: (27 commits)
  Added comments to User model on instance methods and mongoose middleware.
  Updated login template
  Add success flash notification on successful password reset
  Forgot password token changed to hex instead of base64 to avoid having slashes in the url
  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
  Converted reset controller from eventemitter to async.waterfall.
  Added callback to async.waterfall for error handling via express middleware
  Renamed forgot password link
  Updated reset password template
  Cleaned up and refactored reset password template
  Updated schema's default values for password token and expires fields
  Update flash message on successful forgot password request
  Update contributing section
  Update POST /forgot description.
  Refactor Forgot controller
  Converted workflow/eventemitter code to async.waterfall
  Update error flash message, redirect to /forgot if no reset token is found or if it has expired
  Updated expiration of password token to 1hr, updated flash message when email is sent with password recovery instructions.
  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.
  Updated email template text, removed token salting, changed token to base64 (24bit)
  ...
This commit is contained in:
Sahat Yalkabov
2014-02-18 04:07:08 -05:00
10 changed files with 238 additions and 8 deletions

View File

@ -959,7 +959,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
-------

6
app.js
View File

@ -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/:token', resetController.getReset);
app.post('/reset/:token', resetController.postReset);
app.get('/signup', userController.getSignup);
app.post('/signup', userController.postSignup);
app.get('/contact', contactController.getContact);

85
controllers/forgot.js Normal file
View File

@ -0,0 +1,85 @@
var async = require('async');
var crypto = require('crypto');
var nodemailer = require("nodemailer");
var User = require('../models/User');
var secrets = require('../config/secrets');
/**
* GET /forgot
* Forgot Password page.
*/
exports.getForgot = function(req, res) {
if (req.isAuthenticated()) {
return res.redirect('/');
}
res.render('account/forgot', {
title: 'Forgot Password'
});
};
/**
* POST /forgot
* Create a random token, then the send user an email with a reset link.
* @param email
*/
exports.postForgot = function(req, res, next) {
req.assert('email', 'Please enter a valid email address.').isEmail();
var errors = req.validationErrors();
if (errors) {
req.flash('errors', errors);
return res.redirect('/forgot');
}
async.waterfall([
function(done) {
crypto.randomBytes(20, function(err, buf) {
var token = buf.toString('hex');
done(err, token);
});
},
function(token, done) {
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);
});
});
},
function(token, 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: '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: 'An e-mail has been sent to ' + user.email + ' with further instructions.' });
done(err, 'done');
});
}
], function(err) {
if (err) return next(err);
res.redirect('/forgot');
});
};

93
controllers/reset.js Normal file
View File

@ -0,0 +1,93 @@
var async = require('async');
var nodemailer = require('nodemailer');
var User = require('../models/User');
var secrets = require('../config/secrets');
/**
* GET /reset/:token
* Reset Password page.
*/
exports.getReset = function(req, res) {
if (req.isAuthenticated()) {
return res.redirect('/');
}
User
.findOne({ 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'
});
});
};
/**
* POST /reset/:token
* Process the reset password request.
*/
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);
var errors = req.validationErrors();
if (errors) {
req.flash('errors', errors);
return res.redirect('back');
}
async.waterfall([
function(done) {
User
.findOne({ 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('back');
}
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', {
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) {
req.flash('success', { msg: 'Success! Your password has been changed.' });
done(err);
});
}
], function(err) {
if (err) return next(err);
res.redirect('/');
});
};

View File

@ -18,20 +18,23 @@ var userSchema = new mongoose.Schema({
location: { type: String, default: '' },
website: { type: String, default: '' },
picture: { type: String, default: '' }
}
},
resetPasswordToken: String,
resetPasswordExpires: Date
});
/**
* 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) {
@ -42,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);
@ -50,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) {

View File

@ -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"
}
}

View File

@ -32,6 +32,10 @@
background-image: linear-gradient(to bottom, #ffffff 60%, #f8f8f8 100%);
}
.btn-link {
box-shadow: none;
}
// Forms
// -------------------------

15
views/account/forgot.jade Normal file
View File

@ -0,0 +1,15 @@
extends ../layout
block content
.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

View File

@ -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
@ -34,4 +34,4 @@ block content
button.btn.btn-primary(type='submit')
i.fa.fa-unlock-alt
| Login
a.btn.btn-link(href='/forgot') Forgot your password?

17
views/account/reset.jade Normal file
View File

@ -0,0 +1,17 @@
extends ../layout
block content
.col-sm-8.col-sm-offset-2
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')
i.fa.fa-keyboard-o
| Update Password