Added complete password reset function

pull/2/head
Dan Stroot 2014-02-17 10:00:43 -08:00
parent 64598cf20a
commit 1faf279877
8 changed files with 496 additions and 3 deletions

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/: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);

201
controllers/forgot.js Normal file
View File

@ -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');
};

213
controllers/reset.js Normal file
View File

@ -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');
};

View File

@ -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 }
});
/**

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

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

@ -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') <strong> sign in.</strong>
//- 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.

View File

@ -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') <strong> password?</strong>
p Or, do you need to
a(href='signup') <strong> sign up</strong>
| for a #{title} account?

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

@ -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') <strong> Forgot my password</strong>
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.