Merge pull request #3185 from sebgie/issue#3080

Invite user API
This commit is contained in:
Hannah Wolfe 2014-07-02 22:05:11 +01:00
commit 28b3dc57d7
16 changed files with 176 additions and 122 deletions

View File

@ -1,4 +1,3 @@
var dataProvider = require('../models'), var dataProvider = require('../models'),
settings = require('./settings'), settings = require('./settings'),
mail = require('./mail'), mail = require('./mail'),
@ -6,7 +5,7 @@ var dataProvider = require('../models'),
when = require('when'), when = require('when'),
errors = require('../errors'), errors = require('../errors'),
config = require('../config'), config = require('../config'),
ONE_DAY = 86400000, ONE_DAY = 60 * 60 * 24 * 1000,
authentication; authentication;
/** /**
@ -25,6 +24,7 @@ authentication = {
generateResetToken: function generateResetToken(object) { generateResetToken: function generateResetToken(object) {
var expires = Date.now() + ONE_DAY, var expires = Date.now() + ONE_DAY,
email; email;
return utils.checkObject(object, 'passwordreset').then(function (checkedPasswordReset) { return utils.checkObject(object, 'passwordreset').then(function (checkedPasswordReset) {
if (checkedPasswordReset.passwordreset[0].email) { if (checkedPasswordReset.passwordreset[0].email) {
email = checkedPasswordReset.passwordreset[0].email; email = checkedPasswordReset.passwordreset[0].email;
@ -34,37 +34,35 @@ authentication = {
return settings.read({context: {internal: true}, key: 'dbHash'}).then(function (response) { return settings.read({context: {internal: true}, key: 'dbHash'}).then(function (response) {
var dbHash = response.settings[0].value; var dbHash = response.settings[0].value;
return dataProvider.User.generateResetToken(email, expires, dbHash);
}).then(function (resetToken) {
var baseUrl = config().forceAdminSSL ? (config().urlSSL || config().url) : config().url,
siteLink = '<a href="' + baseUrl + '">' + baseUrl + '</a>',
resetUrl = baseUrl.replace(/\/$/, '') + '/ghost/reset/' + resetToken + '/',
resetLink = '<a href="' + resetUrl + '">' + resetUrl + '</a>',
payload = {
mail: [{
message: {
to: email,
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>'
},
options: {}
}]
};
return dataProvider.User.generateResetToken(email, expires, dbHash).then(function (resetToken) { return mail.send(payload);
var baseUrl = config().forceAdminSSL ? (config().urlSSL || config().url) : config().url, }).then(function () {
siteLink = '<a href="' + baseUrl + '">' + baseUrl + '</a>', return when.resolve({passwordreset: [{message: 'Check your email for further instructions.'}]});
resetUrl = baseUrl.replace(/\/$/, '') + '/ghost/reset/' + resetToken + '/', }).otherwise(function (error) {
resetLink = '<a href="' + resetUrl + '">' + resetUrl + '</a>', // TODO: This is kind of sketchy, depends on magic string error.message from Bookshelf.
payload = { if (error && error.message === 'NotFound') {
mail: [{ error = new errors.UnauthorizedError('Invalid email address');
message: { }
to: email, return when.reject(error);
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>'
},
options: {}
}]
};
return mail.send(payload);
}).then(function () {
return when.resolve({passwordreset: [{message: 'Check your email for further instructions.'}]});
}).otherwise(function (error) {
// TODO: This is kind of sketchy, depends on magic string error.message from Bookshelf.
if (error && error.message === 'NotFound') {
error.message = "Invalid email address";
}
return when.reject(new errors.UnauthorizedError(error.message));
});
}); });
}); });
}, },

View File

@ -7,9 +7,12 @@ var when = require('when'),
canThis = require('../permissions').canThis, canThis = require('../permissions').canThis,
errors = require('../errors'), errors = require('../errors'),
utils = require('./utils'), utils = require('./utils'),
globalUtils = require('../utils'),
config = require('../config'),
mail = require('./mail'),
docName = 'users', docName = 'users',
ONE_DAY = 86400000, ONE_DAY = 60 * 60 * 24 * 1000,
users; users;
@ -130,6 +133,80 @@ users = {
}); });
}, },
/**
* ### Invite user
* @param {User} object the user to create
* @returns {Promise(User}} Newly created user
*/
invite: function invite(object, options) {
var newUser,
user;
return canThis(options.context).add.user().then(function () {
return utils.checkObject(object, docName).then(function (checkedUserData) {
newUser = checkedUserData.users[0];
if (newUser.email) {
newUser.name = object.users[0].email.substring(0, newUser.email.indexOf("@"));
newUser.password = globalUtils.uid(50);
newUser.status = 'invited';
// TODO: match user role with db and enforce permissions
newUser.role = 3;
} else {
return when.reject(new errors.BadRequestError('No email provided.'));
}
}).then(function () {
return dataProvider.User.getByEmail(newUser.email);
}).then(function (foundUser) {
if (!foundUser) {
return dataProvider.User.add(newUser, options);
} else {
// only invitations for already invited users are resent
if (foundUser.toJSON().status === 'invited') {
return foundUser;
} else {
return when.reject(new errors.BadRequestError('User is already registered.'));
}
}
}).then(function (invitedUser) {
user = invitedUser.toJSON();
return settings.read({context: {internal: true}, key: 'dbHash'});
}).then(function (response) {
var expires = Date.now() + (14 * ONE_DAY),
dbHash = response.settings[0].value;
return dataProvider.User.generateResetToken(user.email, expires, dbHash);
}).then(function (resetToken) {
var baseUrl = config().forceAdminSSL ? (config().urlSSL || config().url) : config().url,
siteLink = '<a href="' + baseUrl + '">' + baseUrl + '</a>',
resetUrl = baseUrl.replace(/\/$/, '') + '/ghost/signup/' + resetToken + '/',
resetLink = '<a href="' + resetUrl + '">' + resetUrl + '</a>',
payload = {
mail: [{
message: {
to: user.email,
subject: 'Invitation',
html: '<p><strong>Hello!</strong></p>' +
'<p>You have been invited to ' + siteLink + '.</p>' +
'<p>Please follow the link to sign up and publish your ideas:<br><br>' + resetLink + '</p>' +
'<p>Ghost</p>'
},
options: {}
}]
};
return mail.send(payload);
}).then(function () {
return when.resolve({users: [user]});
}).otherwise(function (error) {
if (error && error.type === 'EmailError') {
error.message = 'Error sending email: ' + error.message + ' Please check your email settings and resend the invitation.';
}
return when.reject(error);
});
}, function () {
return when.reject(new errors.NoPermissionError('You do not have permission to add a users.'));
});
},
/** /**
* ### Register * ### Register
* Allow to register a user using the API without beeing authenticated in. Needed to set up the first user. * Allow to register a user using the API without beeing authenticated in. Needed to set up the first user.

View File

@ -9,7 +9,7 @@ function BadRequestError(message) {
} }
BadRequestError.prototype = Object.create(Error.prototype); BadRequestError.prototype = Object.create(Error.prototype);
BadRequestError.prototype.name = "BadRequestError"; BadRequestError.prototype.name = 'BadRequestError';
module.exports = BadRequestError; module.exports = BadRequestError;

View File

@ -9,7 +9,7 @@ function EmailError(message) {
} }
EmailError.prototype = Object.create(Error.prototype); EmailError.prototype = Object.create(Error.prototype);
EmailError.prototype.name = "EmailError"; EmailError.prototype.name = 'EmailError';
module.exports = EmailError; module.exports = EmailError;

View File

@ -9,7 +9,7 @@ function InternalServerError(message) {
} }
InternalServerError.prototype = Object.create(Error.prototype); InternalServerError.prototype = Object.create(Error.prototype);
InternalServerError.prototype.name = "InternalServerError"; InternalServerError.prototype.name = 'InternalServerError';
module.exports = InternalServerError; module.exports = InternalServerError;

View File

@ -9,7 +9,7 @@ function NoPermissionError(message) {
} }
NoPermissionError.prototype = Object.create(Error.prototype); NoPermissionError.prototype = Object.create(Error.prototype);
NoPermissionError.prototype.name = "NoPermissionError"; NoPermissionError.prototype.name = 'NoPermissionError';
module.exports = NoPermissionError; module.exports = NoPermissionError;

View File

@ -9,7 +9,7 @@ function NotFoundError(message) {
} }
NotFoundError.prototype = Object.create(Error.prototype); NotFoundError.prototype = Object.create(Error.prototype);
NotFoundError.prototype.name = "NotFoundError"; NotFoundError.prototype.name = 'NotFoundError';
module.exports = NotFoundError; module.exports = NotFoundError;

View File

@ -9,7 +9,7 @@ function RequestEntityTooLargeError(message) {
} }
RequestEntityTooLargeError.prototype = Object.create(Error.prototype); RequestEntityTooLargeError.prototype = Object.create(Error.prototype);
RequestEntityTooLargeError.prototype.name = "RequestEntityTooLargeError"; RequestEntityTooLargeError.prototype.name = 'RequestEntityTooLargeError';
module.exports = RequestEntityTooLargeError; module.exports = RequestEntityTooLargeError;

View File

@ -9,7 +9,7 @@ function UnauthorizedError(message) {
} }
UnauthorizedError.prototype = Object.create(Error.prototype); UnauthorizedError.prototype = Object.create(Error.prototype);
UnauthorizedError.prototype.name = "UnauthorizedError"; UnauthorizedError.prototype.name = 'UnauthorizedError';
module.exports = UnauthorizedError; module.exports = UnauthorizedError;

View File

@ -9,7 +9,7 @@ function UnsupportedMediaTypeError(message) {
} }
UnsupportedMediaTypeError.prototype = Object.create(Error.prototype); UnsupportedMediaTypeError.prototype = Object.create(Error.prototype);
UnsupportedMediaTypeError.prototype.name = "UnsupportedMediaTypeError"; UnsupportedMediaTypeError.prototype.name = 'UnsupportedMediaTypeError';
module.exports = UnsupportedMediaTypeError; module.exports = UnsupportedMediaTypeError;

View File

@ -12,7 +12,7 @@ function ValidationError(message, offendingProperty) {
} }
ValidationError.prototype = Object.create(Error.prototype); ValidationError.prototype = Object.create(Error.prototype);
ValidationError.prototype.name = "ValidationError"; ValidationError.prototype.name = 'ValidationError';
module.exports = ValidationError; module.exports = ValidationError;

View File

@ -1,44 +1,10 @@
var oauth2orize = require('oauth2orize'), var oauth2orize = require('oauth2orize'),
models = require('../models'), models = require('../models'),
utils = require('../utils'),
oauth; oauth;
/**
* Return a random int, used by `utils.uid()`
*
* @param {Number} min
* @param {Number} max
* @return {Number}
* @api private
*/
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* Return a unique identifier with the given `len`.
*
* utils.uid(10);
* // => "FDaS435D2z"
*
* @param {Number} len
* @return {String}
* @api private
*/
function uid(len) {
var buf = [],
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
charlen = chars.length,
i;
for (i = 1; i < len; i = i + 1) {
buf.push(chars[getRandomInt(0, charlen - 1)]);
}
return buf.join('');
}
oauth = { oauth = {
init: function (oauthServer) { init: function (oauthServer) {
@ -65,8 +31,8 @@ oauth = {
return models.User.check({email: username, password: password}).then(function (user) { return models.User.check({email: username, password: password}).then(function (user) {
//Everything validated, return the access- and refreshtoken //Everything validated, return the access- and refreshtoken
var accessToken = uid(256), var accessToken = utils.uid(256),
refreshToken = uid(256), refreshToken = utils.uid(256),
accessExpires = Date.now() + 3600 * 1000, accessExpires = Date.now() + 3600 * 1000,
refreshExpires = Date.now() + 3600 * 24 * 1000; refreshExpires = Date.now() + 3600 * 24 * 1000;
@ -95,7 +61,7 @@ oauth = {
return done(null, false); return done(null, false);
} else { } else {
var token = model.toJSON(), var token = model.toJSON(),
accessToken = uid(256), accessToken = utils.uid(256),
accessExpires = Date.now() + 3600 * 1000; accessExpires = Date.now() + 3600 * 1000;
if (token.expires > Date.now()) { if (token.expires > Date.now()) {

View File

@ -116,7 +116,6 @@ User = ghostBookshelf.Model.extend({
* **See:** [ghostBookshelf.Model.add](base.js.html#Add) * **See:** [ghostBookshelf.Model.add](base.js.html#Add)
*/ */
add: function (data, options) { add: function (data, options) {
var self = this, var self = this,
// Clone the _user so we don't expose the hashed password unnecessarily // Clone the _user so we don't expose the hashed password unnecessarily
userData = this.filterData(data); userData = this.filterData(data);
@ -130,11 +129,6 @@ User = ghostBookshelf.Model.extend({
*/ */
return validatePasswordLength(userData.password).then(function () { return validatePasswordLength(userData.password).then(function () {
return self.forge().fetch(); return self.forge().fetch();
}).then(function (user) {
// Check if user exists
if (user) {
return when.reject(new Error('A user is already registered. Only one user for now!'));
}
}).then(function () { }).then(function () {
// Generate a new password hash // Generate a new password hash
return generatePasswordHash(data.password); return generatePasswordHash(data.password);
@ -150,31 +144,16 @@ User = ghostBookshelf.Model.extend({
// Assign the userData to our created user so we can pass it back // Assign the userData to our created user so we can pass it back
userData = addedUser; userData = addedUser;
// Add this user to the admin role (assumes admin = role_id: 1) if (!data.role) {
return userData.roles().attach(1); // TODO: needs change when owner role is introduced and setup is changed
data.role = 1;
}
return userData.roles().attach(data.role);
}).then(function (addedUserRole) { }).then(function (addedUserRole) {
/*jshint unused:false*/ /*jshint unused:false*/
// find and return the added user // find and return the added user
return self.findOne({id: userData.id}, options); return self.findOne({id: userData.id}, options);
}); });
/**
* Temporarily replacing the function below with another one that checks
* whether there's anyone registered at all. This is due to #138
* @author javorszky
*/
// return this.forge({email: userData.email}).fetch().then(function (user) {
// if (user !== null) {
// return when.reject(new Error('A user with that email address already exists.'));
// }
// return nodefn.call(bcrypt.hash, _user.password, null, null).then(function (hash) {
// userData.password = hash;
// ghostBookshelf.Model.add.call(UserRole, userRoles);
// return ghostBookshelf.Model.add.call(User, userData);
// }, errors.logAndThrowError);
// }, errors.logAndThrowError);
}, },
permissable: function (userModelOrId, context, loadedPermissions, hasUserPermission, hasAppPermission) { permissable: function (userModelOrId, context, loadedPermissions, hasUserPermission, hasAppPermission) {
@ -233,8 +212,11 @@ User = ghostBookshelf.Model.extend({
check: function (object) { check: function (object) {
var self = this, var self = this,
s; s;
return this.getByEmail(object.email).then(function (user) { return this.getByEmail(object.email).then(function (user) {
if (!user || user.get('status') === 'invited') {
return when.reject(new Error('NotFound'));
}
if (user.get('status') !== 'locked') { if (user.get('status') !== 'locked') {
return nodefn.call(bcrypt.compare, object.password, user.get('password')).then(function (matched) { return nodefn.call(bcrypt.compare, object.password, user.get('password')).then(function (matched) {
if (!matched) { if (!matched) {
@ -296,6 +278,10 @@ User = ghostBookshelf.Model.extend({
generateResetToken: function (email, expires, dbHash) { generateResetToken: function (email, expires, dbHash) {
return this.getByEmail(email).then(function (foundUser) { return this.getByEmail(email).then(function (foundUser) {
if (!foundUser) {
return when.reject(new Error('NotFound'));
}
var hash = crypto.createHash('sha256'), var hash = crypto.createHash('sha256'),
text = ""; text = "";
@ -398,8 +384,8 @@ User = ghostBookshelf.Model.extend({
gravatarLookup: function (userData) { gravatarLookup: function (userData) {
var gravatarUrl = '//www.gravatar.com/avatar/' + var gravatarUrl = '//www.gravatar.com/avatar/' +
crypto.createHash('md5').update(userData.email.toLowerCase().trim()).digest('hex') + crypto.createHash('md5').update(userData.email.toLowerCase().trim()).digest('hex') +
"?d=404", "?d=404",
checkPromise = when.defer(); checkPromise = when.defer();
http.get('http:' + gravatarUrl, function (res) { http.get('http:' + gravatarUrl, function (res) {
@ -427,12 +413,9 @@ User = ghostBookshelf.Model.extend({
var userWithEmail = users.find(function (user) { var userWithEmail = users.find(function (user) {
return user.get('email').toLowerCase() === email.toLowerCase(); return user.get('email').toLowerCase() === email.toLowerCase();
}); });
if (userWithEmail) { if (userWithEmail) {
return when.resolve(userWithEmail); return when.resolve(userWithEmail);
} }
return when.reject(new Error('NotFound'));
}); });
} }
}); });

View File

@ -22,6 +22,7 @@ apiRoutes = function (middleware) {
router.get('/ghost/api/v0.1/users/:id/', api.http(api.users.read)); router.get('/ghost/api/v0.1/users/:id/', api.http(api.users.read));
router.put('/ghost/api/v0.1/users/password/', api.http(api.users.changePassword)); router.put('/ghost/api/v0.1/users/password/', api.http(api.users.changePassword));
router.put('/ghost/api/v0.1/users/:id/', api.http(api.users.edit)); router.put('/ghost/api/v0.1/users/:id/', api.http(api.users.edit));
router.post('/ghost/api/v0.1/users/', api.http(api.users.invite));
router['delete']('/ghost/api/v0.1/users/:id/', api.http(api.users.destroy)); router['delete']('/ghost/api/v0.1/users/:id/', api.http(api.users.destroy));
// ## Tags // ## Tags

View File

@ -0,0 +1,38 @@
/**
* Return a random int, used by `utils.uid()`
*
* @param {Number} min
* @param {Number} max
* @return {Number}
* @api private
*/
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
var utils = {
/**
* Return a unique identifier with the given `len`.
*
* utils.uid(10);
* // => "FDaS435D2z"
*
* @param {Number} len
* @return {String}
* @api private
*/
uid: function (len) {
var buf = [],
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
charlen = chars.length,
i;
for (i = 1; i < len; i = i + 1) {
buf.push(chars[getRandomInt(0, charlen - 1)]);
}
return buf.join('');
}
};
module.exports = utils;

View File

@ -150,15 +150,6 @@ describe('User Model', function run() {
}).catch(done); }).catch(done);
}); });
it('can\'t add second', function (done) {
var userData = testUtils.DataGenerator.forModel.users[1];
return UserModel.add(userData, {user: 1}).then(done, function (failure) {
failure.message.should.eql('A user is already registered. Only one user for now!');
done();
}).catch(done);
});
it('can findAll', function (done) { it('can findAll', function (done) {
UserModel.findAll().then(function (results) { UserModel.findAll().then(function (results) {