diff --git a/README.md b/README.md index 9a68758768..fe76ea6c07 100644 --- a/README.md +++ b/README.md @@ -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 ------- diff --git a/app.js b/app.js index d8e6cdfd5f..cf07fc02c7 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/:token', resetController.getReset); +app.post('/reset/: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..f0244097f4 --- /dev/null +++ b/controllers/forgot.js @@ -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'); + }); +}; diff --git a/controllers/reset.js b/controllers/reset.js new file mode 100644 index 0000000000..88d2cfd1cd --- /dev/null +++ b/controllers/reset.js @@ -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('/'); + }); +}; diff --git a/models/User.js b/models/User.js index f21d1596ab..ef1a3d8683 100644 --- a/models/User.js +++ b/models/User.js @@ -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) { 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/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 // ------------------------- diff --git a/views/account/forgot.jade b/views/account/forgot.jade new file mode 100644 index 0000000000..70029f16b8 --- /dev/null +++ b/views/account/forgot.jade @@ -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 diff --git a/views/account/login.jade b/views/account/login.jade index 8bfb653399..2eeee1ceb8 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 @@ -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? diff --git a/views/account/reset.jade b/views/account/reset.jade new file mode 100644 index 0000000000..becf6b0ee4 --- /dev/null +++ b/views/account/reset.jade @@ -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