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.