Skip to content

Commit

Permalink
Improved Password Reset Tool
Browse files Browse the repository at this point in the history
Closes TryGhost#1471

- add api and User model methods for generating and validating tokens
- add routes and handlers for reset password pages
- add client styles and views for reset password form
- some basic integration tests for User model methods
  • Loading branch information
jgable committed Nov 22, 2013
1 parent 216dd75 commit 34e4530
Show file tree
Hide file tree
Showing 10 changed files with 429 additions and 59 deletions.
10 changes: 6 additions & 4 deletions core/client/assets/sass/layouts/auth.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

.ghost-login,
.ghost-signup,
.ghost-forgotten {
.ghost-forgotten,
.ghost-reset {
color: $midgrey;
background: $darkgrey;

Expand All @@ -35,7 +36,8 @@

.login-box,
.signup-box,
.forgotten-box {
.forgotten-box,
.reset-box {
max-width: 530px;
height: 90%;
margin: 0 auto;
Expand Down Expand Up @@ -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%);
Expand Down
7 changes: 6 additions & 1 deletion core/client/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
'register/' : 'register',
'signup/' : 'signup',
'signin/' : 'login',
'forgotten/' : 'forgotten'
'forgotten/' : 'forgotten',
'reset/:token/' : 'reset'
},

signup: function () {
Expand All @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions core/client/tpl/reset.hbs
Original file line number Diff line number Diff line change
@@ -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>
109 changes: 97 additions & 12 deletions core/client/views/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@

initialize: function () {
this.render();
$(".js-login-box").css({"opacity": 0}).animate({"opacity": 1}, 500, function () {
$("[name='email']").focus();
});
},

templateName: "login",
Expand All @@ -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(),
Expand Down Expand Up @@ -61,9 +65,6 @@

initialize: function () {
this.render();
$(".js-signup-box").css({"opacity": 0}).animate({"opacity": 1}, 500, function () {
$("[name='name']").focus();
});
},

templateName: "signup",
Expand All @@ -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.
Expand Down Expand Up @@ -119,9 +130,6 @@

initialize: function () {
this.render();
$(".js-forgotten-box").css({"opacity": 0}).animate({"opacity": 1}, 500, function () {
$("[name='email']").focus();
});
},

templateName: "forgotten",
Expand All @@ -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();

Expand Down Expand Up @@ -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;
}
});
}());
18 changes: 15 additions & 3 deletions core/server/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
}
};

Expand Down
86 changes: 69 additions & 17 deletions core/server/controllers/admin.js
Original file line number Diff line number Diff line change
@@ -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'),
Expand Down Expand Up @@ -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,
Expand All @@ -104,7 +105,6 @@ adminControllers = {
}, function (error) {
res.send(401, {error: error.message});
});

},
'signup': function (req, res) {
/*jslint unparam:true*/
Expand All @@ -114,7 +114,6 @@ adminControllers = {
adminNav: setSelected(adminNavbar, 'login')
});
},

'doRegister': function (req, res) {
var name = req.body.name,
email = req.body.email,
Expand All @@ -134,9 +133,7 @@ adminControllers = {
}).otherwise(function (error) {
res.json(401, {error: error.message});
});

},

'forgotten': function (req, res) {
/*jslint unparam:true*/
res.render('forgotten', {
Expand All @@ -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'
};
Expand All @@ -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;
Expand Down
Loading

0 comments on commit 34e4530

Please sign in to comment.