Merge pull request #1534 from jgable/passwordReset

Improved Password Reset Tool
This commit is contained in:
Hannah Wolfe 2013-11-23 08:44:08 -08:00
commit 2701f3e664
10 changed files with 429 additions and 59 deletions

View File

@ -15,7 +15,8 @@
.ghost-login,
.ghost-signup,
.ghost-forgotten {
.ghost-forgotten,
.ghost-reset {
color: $midgrey;
background: $darkgrey;
@ -35,7 +36,8 @@
.login-box,
.signup-box,
.forgotten-box {
.forgotten-box,
.reset-box {
max-width: 530px;
height: 90%;
margin: 0 auto;
@ -177,10 +179,10 @@
/* =============================================================================
2. Signup
2. Signup and Reset
============================================================================= */
#signup {
#signup, #reset {
@include box-sizing(border-box);
max-width: 280px;
color: lighten($midgrey, 15%);

View File

@ -13,7 +13,8 @@
'register/' : 'register',
'signup/' : 'signup',
'signin/' : 'login',
'forgotten/' : 'forgotten'
'forgotten/' : 'forgotten',
'reset/:token/' : 'reset'
},
signup: function () {
@ -28,6 +29,10 @@
Ghost.currentView = new Ghost.Views.Forgotten({ el: '.js-forgotten-box' });
},
reset: function (token) {
Ghost.currentView = new Ghost.Views.ResetPassword({ el: '.js-reset-box', token: token });
},
blog: function () {
var posts = new Ghost.Collections.Posts();
NProgress.start();

View File

@ -0,0 +1,9 @@
<form id="reset" method="post" novalidate="novalidate">
<div class="password-wrap">
<input class="password" type="password" placeholder="Password" name="newpassword" />
</div>
<div class="password-wrap">
<input class="password" type="password" placeholder="Confirm Password" name="ne2password" />
</div>
<button class="button-save" type="submit">Reset Password</button>
</form>

View File

@ -6,9 +6,6 @@
initialize: function () {
this.render();
$(".js-login-box").css({"opacity": 0}).animate({"opacity": 1}, 500, function () {
$("[name='email']").focus();
});
},
templateName: "login",
@ -17,6 +14,13 @@
'submit #login': 'submitHandler'
},
afterRender: function () {
var self = this;
this.$el.css({"opacity": 0}).animate({"opacity": 1}, 500, function () {
self.$("[name='email']").focus();
});
},
submitHandler: function (event) {
event.preventDefault();
var email = this.$el.find('.email').val(),
@ -61,9 +65,6 @@
initialize: function () {
this.render();
$(".js-signup-box").css({"opacity": 0}).animate({"opacity": 1}, 500, function () {
$("[name='name']").focus();
});
},
templateName: "signup",
@ -72,11 +73,21 @@
'submit #signup': 'submitHandler'
},
afterRender: function () {
var self = this;
this.$el
.css({"opacity": 0})
.animate({"opacity": 1}, 500, function () {
self.$("[name='name']").focus();
});
},
submitHandler: function (event) {
event.preventDefault();
var name = this.$el.find('.name').val(),
email = this.$el.find('.email').val(),
password = this.$el.find('.password').val();
var name = this.$('.name').val(),
email = this.$('.email').val(),
password = this.$('.password').val();
// This is needed due to how error handling is done. If this is not here, there will not be a time
// when there is no error.
@ -119,9 +130,6 @@
initialize: function () {
this.render();
$(".js-forgotten-box").css({"opacity": 0}).animate({"opacity": 1}, 500, function () {
$("[name='email']").focus();
});
},
templateName: "forgotten",
@ -130,6 +138,13 @@
'submit #forgotten': 'submitHandler'
},
afterRender: function () {
var self = this;
this.$el.css({"opacity": 0}).animate({"opacity": 1}, 500, function () {
self.$("[name='email']").focus();
});
},
submitHandler: function (event) {
event.preventDefault();
@ -166,4 +181,74 @@
}
}
});
Ghost.Views.ResetPassword = Ghost.View.extend({
templateName: 'reset',
events: {
'submit #reset': 'submitHandler'
},
initialize: function (attrs) {
attrs = attrs || {};
this.token = attrs.token;
this.render();
},
afterRender: function () {
var self = this;
this.$el.css({"opacity": 0}).animate({"opacity": 1}, 500, function () {
self.$("[name='newpassword']").focus();
});
},
submitHandler: function (ev) {
ev.preventDefault();
var self = this,
newPassword = this.$('input[name="newpassword"]').val(),
ne2Password = this.$('input[name="ne2password"]').val();
if (newPassword !== ne2Password) {
Ghost.notifications.addItem({
type: 'error',
message: "Your passwords do not match.",
status: 'passive'
});
return;
}
this.$('input, button').prop('disabled', true);
$.ajax({
url: '/ghost/reset/' + this.token + '/',
type: 'POST',
headers: {
'X-CSRF-Token': $("meta[name='csrf-param']").attr('content')
},
data: {
newpassword: newPassword,
ne2password: ne2Password
},
success: function (msg) {
window.location.href = msg.redirect;
},
error: function (xhr) {
self.$('input, button').prop('disabled', false);
Ghost.notifications.clearEverything();
Ghost.notifications.addItem({
type: 'error',
message: Ghost.Views.Utils.getRequestErrorMessage(xhr),
status: 'passive'
});
}
});
return false;
}
});
}());

View File

@ -20,7 +20,8 @@ var Ghost = require('../../ghost'),
settingsObject,
settingsCollection,
settingsFilter,
filteredUserAttributes = ['password', 'created_by', 'updated_by', 'last_login'];
filteredUserAttributes = ['password', 'created_by', 'updated_by', 'last_login'],
ONE_DAY = 86400000;
// ## Posts
posts = {
@ -218,8 +219,19 @@ users = {
return dataProvider.User.changePassword(userData);
},
forgottenPassword: function forgottenPassword(email) {
return dataProvider.User.forgottenPassword(email);
generateResetToken: function generateResetToken(email) {
// TODO: Do we want to be able to pass this in?
var expires = Date.now() + ONE_DAY;
return dataProvider.User.generateResetToken(email, expires, ghost.dbHash);
},
validateToken: function validateToken(token) {
return dataProvider.User.validateToken(token, ghost.dbHash);
},
resetPassword: function resetPassword(token, newPassword, ne2Password) {
return dataProvider.User.resetPassword(token, newPassword, ne2Password, ghost.dbHash);
}
};

View File

@ -1,6 +1,7 @@
var Ghost = require('../../ghost'),
_ = require('underscore'),
path = require('path'),
when = require('when'),
api = require('../api'),
errors = require('../errorHandling'),
storage = require('../storage'),
@ -94,7 +95,7 @@ adminControllers = {
}
},
'changepw': function (req, res) {
api.users.changePassword({
return api.users.changePassword({
currentUser: req.session.user,
oldpw: req.body.password,
newpw: req.body.newpassword,
@ -104,7 +105,6 @@ adminControllers = {
}, function (error) {
res.send(401, {error: error.message});
});
},
'signup': function (req, res) {
/*jslint unparam:true*/
@ -114,7 +114,6 @@ adminControllers = {
adminNav: setSelected(adminNavbar, 'login')
});
},
'doRegister': function (req, res) {
var name = req.body.name,
email = req.body.email,
@ -134,9 +133,7 @@ adminControllers = {
}).otherwise(function (error) {
res.json(401, {error: error.message});
});
},
'forgotten': function (req, res) {
/*jslint unparam:true*/
res.render('forgotten', {
@ -145,26 +142,27 @@ adminControllers = {
adminNav: setSelected(adminNavbar, 'login')
});
},
'resetPassword': function (req, res) {
'generateResetToken': function (req, res) {
var email = req.body.email;
api.users.forgottenPassword(email).then(function (user) {
var message = {
api.users.generateResetToken(email).then(function (token) {
var siteLink = '<a href="' + ghost.config().url + '">' + ghost.config().url + '</a>',
resetUrl = ghost.config().url + '/ghost/reset/' + token + '/',
resetLink = '<a href="' + resetUrl + '">' + resetUrl + '</a>',
message = {
to: email,
subject: 'Your new password',
html: "<p><strong>Hello!</strong></p>" +
"<p>You've reset your password. Here's the new one: " + user.newPassword + "</p>" +
"<p>Ghost <br/>" +
'<a href="' + ghost.config().url + '">' +
ghost.config().url + '</a></p>'
subject: 'Reset Password',
html: '<p><strong>Hello!</strong></p>' +
'<p>A request has been made to reset the password on the site ' + siteLink + '.</p>' +
'<p>Please follow the link below to reset your password:<br><br>' + resetLink + '</p>' +
'<p>Ghost</p>'
};
return ghost.mail.send(message);
}).then(function success() {
var notification = {
type: 'success',
message: 'Your password was changed successfully. Check your email for details.',
message: 'Check your email for further instructions',
status: 'passive',
id: 'successresetpw'
};
@ -174,8 +172,62 @@ adminControllers = {
});
}, function failure(error) {
// TODO: This is kind of sketchy, depends on magic string error.message from Bookshelf.
// TODO: It's debatable whether we want to just tell the user we sent the email in this case or not, we are giving away sensitive info here.
if (error && error.message === 'EmptyResponse') {
error.message = "Invalid email address";
}
res.json(401, {error: error.message});
}).otherwise(errors.logAndThrowError);
});
},
'reset': function (req, res) {
// Validate the request token
var token = req.params.token;
api.users.validateToken(token).then(function () {
// Render the reset form
res.render('reset', {
bodyClass: 'ghost-reset',
hideNavbar: true,
adminNav: setSelected(adminNavbar, 'reset')
});
}).otherwise(function (err) {
// Redirect to forgotten if invalid token
var notification = {
type: 'error',
message: 'Invalid or expired token',
status: 'persistent',
id: 'errorinvalidtoken'
};
errors.logError(err, 'admin.js', "Please check the provided token for validity and expiration.");
return api.notifications.add(notification).then(function () {
res.redirect('/ghost/forgotten');
});
});
},
'resetPassword': function (req, res) {
var token = req.params.token,
newPassword = req.param('newpassword'),
ne2Password = req.param('ne2password');
api.users.resetPassword(token, newPassword, ne2Password).then(function () {
var notification = {
type: 'success',
message: 'Password changed successfully.',
status: 'passive',
id: 'successresetpw'
};
return api.notifications.add(notification).then(function () {
res.json(200, {redirect: '/ghost/signin/'});
});
}).otherwise(function (err) {
// TODO: Better error message if we can tell whether the passwords didn't match or something
res.json(401, {error: err.message});
});
},
'logout': function (req, res) {
req.session = null;

View File

@ -13,10 +13,9 @@ var User,
http = require('http'),
crypto = require('crypto');
function validatePasswordLength(password) {
try {
ghostBookshelf.validator.check(password, "Your must be at least 8 characters long.").len(8);
ghostBookshelf.validator.check(password, "Your password must be at least 8 characters long.").len(8);
} catch (error) {
return when.reject(error);
}
@ -24,6 +23,14 @@ function validatePasswordLength(password) {
return when.resolve();
}
function generatePasswordHash(password) {
// Generate a new salt
return nodefn.call(bcrypt.genSalt).then(function (salt) {
// Hash the provided password with bcrypt
return nodefn.call(bcrypt.hash, password, salt);
});
}
User = ghostBookshelf.Model.extend({
tableName: 'users',
@ -107,11 +114,8 @@ User = ghostBookshelf.Model.extend({
return when.reject(new Error('A user is already registered. Only one user for now!'));
}
}).then(function () {
// Generate a new salt
return nodefn.call(bcrypt.genSalt);
}).then(function (salt) {
// Hash the provided password with bcrypt
return nodefn.call(bcrypt.hash, _user.password, salt);
// Generate a new password hash
return generatePasswordHash(_user.password);
}).then(function (hash) {
// Assign the hashed password
userData.password = hash;
@ -205,21 +209,85 @@ User = ghostBookshelf.Model.extend({
});
},
forgottenPassword: function (email) {
var newPassword = Math.random().toString(36).slice(2, 12), // This is magick
user = null;
generateResetToken: function (email, expires, dbHash) {
return this.forge({email: email.toLocaleLowerCase()}).fetch({require: true}).then(function (foundUser) {
var hash = crypto.createHash('sha256'),
text = "";
return this.forge({email: email}).fetch({require: true}).then(function (_user) {
user = _user;
return nodefn.call(bcrypt.genSalt);
}).then(function (salt) {
return nodefn.call(bcrypt.hash, newPassword, salt);
}).then(function (hash) {
user.save({password: hash});
return { user: user, newPassword: newPassword };
}, function (error) {
/*jslint unparam:true*/
return when.reject(new Error('There is no user by that email address. Check again.'));
// Token:
// BASE64(TIMESTAMP + email + HASH(TIMESTAMP + email + oldPasswordHash + dbHash ))
hash.update(String(expires));
hash.update(email.toLocaleLowerCase());
hash.update(foundUser.get('password'));
hash.update(String(dbHash));
text += [expires, email, hash.digest('base64')].join('|');
return new Buffer(text).toString('base64');
});
},
validateToken: function (token, dbHash) {
// TODO: Is there a chance the use of ascii here will cause problems if oldPassword has weird characters?
var tokenText = new Buffer(token, 'base64').toString('ascii'),
parts,
expires,
email;
parts = tokenText.split('|');
// Check if invalid structure
if (!parts || parts.length !== 3) {
return when.reject(new Error("Invalid token structure"));
}
expires = parseInt(parts[0], 10);
email = parts[1];
if (isNaN(expires)) {
return when.reject(new Error("Invalid token expiration"));
}
// This is easy to fake, but still check anyway.
if (expires < Date.now()) {
return when.reject(new Error("Expired token"));
}
return this.generateResetToken(email, expires, dbHash).then(function (generatedToken) {
// Check for matching tokens
if (token === generatedToken) {
return when.resolve(email);
}
return when.reject(new Error("Invalid token"));
});
},
resetPassword: function (token, newPassword, ne2Password, dbHash) {
var self = this;
if (newPassword !== ne2Password) {
return when.reject(new Error("Your new passwords do not match"));
}
return validatePasswordLength(newPassword).then(function () {
// Validate the token; returns the email address from token
return self.validateToken(token, dbHash);
}).then(function (email) {
// Fetch the user by email, and hash the password at the same time.
return when.join(
self.forge({email: email.toLocaleLowerCase()}).fetch({require: true}),
generatePasswordHash(newPassword)
);
}).then(function (results) {
// Update the user with the new password hash
var foundUser = results[0],
passwordHash = results[1];
foundUser.save({password: passwordHash});
return foundUser;
});
},

View File

@ -30,7 +30,9 @@ module.exports = function (server) {
server.get('/ghost/signin/', redirectToSignup, middleware.redirectToDashboard, admin.login);
server.get('/ghost/signup/', middleware.redirectToDashboard, admin.signup);
server.get('/ghost/forgotten/', middleware.redirectToDashboard, admin.forgotten);
server.post('/ghost/forgotten/', admin.resetPassword);
server.post('/ghost/forgotten/', admin.generateResetToken);
server.get('/ghost/reset/:token', admin.reset);
server.post('/ghost/reset/:token', admin.resetPassword);
server.post('/ghost/signin/', admin.auth);
server.post('/ghost/signup/', admin.doRegister);
server.post('/ghost/changepw/', middleware.auth, admin.changepw);

View File

@ -0,0 +1,4 @@
{{!< default}}
<section class="reset-box js-reset-box">
</section>

View File

@ -5,6 +5,7 @@ var testUtils = require('../../utils'),
_ = require('underscore'),
errors = require('../../../server/errorHandling'),
sinon = require('sinon'),
uuid = require('node-uuid'),
// Stuff we are testing
Models = require('../../../server/models');
@ -222,6 +223,136 @@ describe('User Model', function run() {
}).then(null, done);
});
it('can generate reset token', function (done) {
// Expires in one minute
var expires = Date.now() + 60000,
dbHash = uuid.v4();
UserModel.browse().then(function (results) {
return UserModel.generateResetToken(results.models[0].attributes.email, expires, dbHash);
}).then(function (token) {
should.exist(token);
token.length.should.be.above(0);
done();
}).then(null, done);
});
it('can validate a reset token', function (done) {
// Expires in one minute
var expires = Date.now() + 60000,
dbHash = uuid.v4();
UserModel.browse().then(function (results) {
return UserModel.generateResetToken(results.models[0].attributes.email, expires, dbHash);
}).then(function (token) {
return UserModel.validateToken(token, dbHash);
}).then(function () {
done();
}).then(null, done);
});
it('can reset a password with a valid token', function (done) {
// Expires in one minute
var origPassword,
expires = Date.now() + 60000,
dbHash = uuid.v4();
UserModel.browse().then(function (results) {
var firstUser = results.models[0],
origPassword = firstUser.attributes.password;
should.exist(origPassword);
return UserModel.generateResetToken(firstUser.attributes.email, expires, dbHash);
}).then(function (token) {
return UserModel.resetPassword(token, 'newpassword', 'newpassword', dbHash);
}).then(function (resetUser) {
var resetPassword = resetUser.get('password');
should.exist(resetPassword);
resetPassword.should.not.equal(origPassword);
done();
}).then(null, done);
});
it('doesn\'t allow expired timestamp tokens', function (done) {
var email,
// Expired one minute ago
expires = Date.now() - 60000,
dbHash = uuid.v4();
UserModel.browse().then(function (results) {
// Store email for later
email = results.models[0].attributes.email;
return UserModel.generateResetToken(email, expires, dbHash);
}).then(function (token) {
return UserModel.validateToken(token, dbHash);
}).then(function () {
throw new Error("Allowed expired token");
}, function (err) {
should.exist(err);
err.message.should.equal("Expired token");
done();
});
});
it('doesn\'t allow tampered timestamp tokens', function (done) {
// Expired one minute ago
var expires = Date.now() - 60000,
dbHash = uuid.v4();
UserModel.browse().then(function (results) {
return UserModel.generateResetToken(results.models[0].attributes.email, expires, dbHash);
}).then(function (token) {
var tokenText = new Buffer(token, 'base64').toString('ascii'),
parts = tokenText.split('|'),
fakeExpires,
fakeToken;
fakeExpires = Date.now() + 60000;
fakeToken = [String(fakeExpires), parts[1], parts[2]].join('|');
fakeToken = new Buffer(fakeToken).toString('base64');
return UserModel.validateToken(fakeToken, dbHash);
}).then(function () {
throw new Error("allowed invalid token");
}, function (err) {
should.exist(err);
err.message.should.equal("Invalid token");
done();
});
});
});
});