From 79a80b67acf55e7332189f3efb09906dccc8b3e8 Mon Sep 17 00:00:00 2001 From: Sebastian Gierlinger Date: Wed, 2 Jul 2014 16:22:18 +0200 Subject: [PATCH] Invite user API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #3080 - added users.invite() to add user from email with random password - added `GET /ghost/api/v0.1/users/` to invite users and resend invitations - removed one user limit - added global utils for uid generation - changed some „“ to ‚‘ --- core/server/api/authentication.js | 62 +++++++-------- core/server/api/users.js | 79 ++++++++++++++++++- core/server/errors/badrequesterror.js | 2 +- core/server/errors/emailerror.js | 2 +- core/server/errors/internalservererror.js | 2 +- core/server/errors/nopermissionerror.js | 2 +- core/server/errors/notfounderror.js | 2 +- core/server/errors/requesttoolargeerror.js | 2 +- core/server/errors/unauthorizederror.js | 2 +- core/server/errors/unsupportedmediaerror.js | 2 +- core/server/errors/validationerror.js | 2 +- core/server/middleware/oauth.js | 44 ++--------- core/server/models/user.js | 47 ++++------- core/server/routes/api.js | 1 + core/server/utils/index.js | 38 +++++++++ .../integration/model/model_users_spec.js | 9 --- 16 files changed, 176 insertions(+), 122 deletions(-) create mode 100644 core/server/utils/index.js diff --git a/core/server/api/authentication.js b/core/server/api/authentication.js index a08d067589..7af370be88 100644 --- a/core/server/api/authentication.js +++ b/core/server/api/authentication.js @@ -1,4 +1,3 @@ - var dataProvider = require('../models'), settings = require('./settings'), mail = require('./mail'), @@ -6,7 +5,7 @@ var dataProvider = require('../models'), when = require('when'), errors = require('../errors'), config = require('../config'), - ONE_DAY = 86400000, + ONE_DAY = 60 * 60 * 24 * 1000, authentication; /** @@ -25,6 +24,7 @@ authentication = { generateResetToken: function generateResetToken(object) { var expires = Date.now() + ONE_DAY, email; + return utils.checkObject(object, 'passwordreset').then(function (checkedPasswordReset) { if (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) { 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 = '' + baseUrl + '', + resetUrl = baseUrl.replace(/\/$/, '') + '/ghost/reset/' + resetToken + '/', + resetLink = '' + resetUrl + '', + payload = { + mail: [{ + message: { + to: email, + subject: 'Reset Password', + html: '

Hello!

' + + '

A request has been made to reset the password on the site ' + siteLink + '.

' + + '

Please follow the link below to reset your password:

' + resetLink + '

' + + '

Ghost

' + }, + options: {} + }] + }; - return dataProvider.User.generateResetToken(email, expires, dbHash).then(function (resetToken) { - var baseUrl = config().forceAdminSSL ? (config().urlSSL || config().url) : config().url, - siteLink = '' + baseUrl + '', - resetUrl = baseUrl.replace(/\/$/, '') + '/ghost/reset/' + resetToken + '/', - resetLink = '' + resetUrl + '', - payload = { - mail: [{ - message: { - to: email, - subject: 'Reset Password', - html: '

Hello!

' + - '

A request has been made to reset the password on the site ' + siteLink + '.

' + - '

Please follow the link below to reset your password:

' + resetLink + '

' + - '

Ghost

' - }, - 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)); - }); + 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 = new errors.UnauthorizedError('Invalid email address'); + } + return when.reject(error); }); }); }, diff --git a/core/server/api/users.js b/core/server/api/users.js index 126ac20953..6895c61822 100644 --- a/core/server/api/users.js +++ b/core/server/api/users.js @@ -7,9 +7,12 @@ var when = require('when'), canThis = require('../permissions').canThis, errors = require('../errors'), utils = require('./utils'), + globalUtils = require('../utils'), + config = require('../config'), + mail = require('./mail'), docName = 'users', - ONE_DAY = 86400000, + ONE_DAY = 60 * 60 * 24 * 1000, 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 = '' + baseUrl + '', + resetUrl = baseUrl.replace(/\/$/, '') + '/ghost/signup/' + resetToken + '/', + resetLink = '' + resetUrl + '', + payload = { + mail: [{ + message: { + to: user.email, + subject: 'Invitation', + html: '

Hello!

' + + '

You have been invited to ' + siteLink + '.

' + + '

Please follow the link to sign up and publish your ideas:

' + resetLink + '

' + + '

Ghost

' + }, + 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 * Allow to register a user using the API without beeing authenticated in. Needed to set up the first user. diff --git a/core/server/errors/badrequesterror.js b/core/server/errors/badrequesterror.js index c37a133eda..12401f03e0 100644 --- a/core/server/errors/badrequesterror.js +++ b/core/server/errors/badrequesterror.js @@ -9,7 +9,7 @@ function BadRequestError(message) { } BadRequestError.prototype = Object.create(Error.prototype); -BadRequestError.prototype.name = "BadRequestError"; +BadRequestError.prototype.name = 'BadRequestError'; module.exports = BadRequestError; \ No newline at end of file diff --git a/core/server/errors/emailerror.js b/core/server/errors/emailerror.js index 48910e7fa3..e8053863db 100644 --- a/core/server/errors/emailerror.js +++ b/core/server/errors/emailerror.js @@ -9,7 +9,7 @@ function EmailError(message) { } EmailError.prototype = Object.create(Error.prototype); -EmailError.prototype.name = "EmailError"; +EmailError.prototype.name = 'EmailError'; module.exports = EmailError; \ No newline at end of file diff --git a/core/server/errors/internalservererror.js b/core/server/errors/internalservererror.js index bb4cfd9a03..67c4eaf3bf 100644 --- a/core/server/errors/internalservererror.js +++ b/core/server/errors/internalservererror.js @@ -9,7 +9,7 @@ function InternalServerError(message) { } InternalServerError.prototype = Object.create(Error.prototype); -InternalServerError.prototype.name = "InternalServerError"; +InternalServerError.prototype.name = 'InternalServerError'; module.exports = InternalServerError; \ No newline at end of file diff --git a/core/server/errors/nopermissionerror.js b/core/server/errors/nopermissionerror.js index 05096510d9..d9bd5aac2a 100644 --- a/core/server/errors/nopermissionerror.js +++ b/core/server/errors/nopermissionerror.js @@ -9,7 +9,7 @@ function NoPermissionError(message) { } NoPermissionError.prototype = Object.create(Error.prototype); -NoPermissionError.prototype.name = "NoPermissionError"; +NoPermissionError.prototype.name = 'NoPermissionError'; module.exports = NoPermissionError; \ No newline at end of file diff --git a/core/server/errors/notfounderror.js b/core/server/errors/notfounderror.js index b0d11cd4c1..c216a3a99a 100644 --- a/core/server/errors/notfounderror.js +++ b/core/server/errors/notfounderror.js @@ -9,7 +9,7 @@ function NotFoundError(message) { } NotFoundError.prototype = Object.create(Error.prototype); -NotFoundError.prototype.name = "NotFoundError"; +NotFoundError.prototype.name = 'NotFoundError'; module.exports = NotFoundError; \ No newline at end of file diff --git a/core/server/errors/requesttoolargeerror.js b/core/server/errors/requesttoolargeerror.js index 34cdf1db37..c8326c1033 100644 --- a/core/server/errors/requesttoolargeerror.js +++ b/core/server/errors/requesttoolargeerror.js @@ -9,7 +9,7 @@ function RequestEntityTooLargeError(message) { } RequestEntityTooLargeError.prototype = Object.create(Error.prototype); -RequestEntityTooLargeError.prototype.name = "RequestEntityTooLargeError"; +RequestEntityTooLargeError.prototype.name = 'RequestEntityTooLargeError'; module.exports = RequestEntityTooLargeError; \ No newline at end of file diff --git a/core/server/errors/unauthorizederror.js b/core/server/errors/unauthorizederror.js index 8bc1d34fa8..666e35e803 100644 --- a/core/server/errors/unauthorizederror.js +++ b/core/server/errors/unauthorizederror.js @@ -9,7 +9,7 @@ function UnauthorizedError(message) { } UnauthorizedError.prototype = Object.create(Error.prototype); -UnauthorizedError.prototype.name = "UnauthorizedError"; +UnauthorizedError.prototype.name = 'UnauthorizedError'; module.exports = UnauthorizedError; \ No newline at end of file diff --git a/core/server/errors/unsupportedmediaerror.js b/core/server/errors/unsupportedmediaerror.js index 6592ccc964..18c740ce3e 100644 --- a/core/server/errors/unsupportedmediaerror.js +++ b/core/server/errors/unsupportedmediaerror.js @@ -9,7 +9,7 @@ function UnsupportedMediaTypeError(message) { } UnsupportedMediaTypeError.prototype = Object.create(Error.prototype); -UnsupportedMediaTypeError.prototype.name = "UnsupportedMediaTypeError"; +UnsupportedMediaTypeError.prototype.name = 'UnsupportedMediaTypeError'; module.exports = UnsupportedMediaTypeError; \ No newline at end of file diff --git a/core/server/errors/validationerror.js b/core/server/errors/validationerror.js index cfd8a7fa3e..779b65e0f1 100644 --- a/core/server/errors/validationerror.js +++ b/core/server/errors/validationerror.js @@ -12,7 +12,7 @@ function ValidationError(message, offendingProperty) { } ValidationError.prototype = Object.create(Error.prototype); -ValidationError.prototype.name = "ValidationError"; +ValidationError.prototype.name = 'ValidationError'; module.exports = ValidationError; diff --git a/core/server/middleware/oauth.js b/core/server/middleware/oauth.js index 9d1f595ff6..5695c2257b 100644 --- a/core/server/middleware/oauth.js +++ b/core/server/middleware/oauth.js @@ -1,44 +1,10 @@ var oauth2orize = require('oauth2orize'), - models = require('../models'), + models = require('../models'), + utils = require('../utils'), 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 = { init: function (oauthServer) { @@ -65,8 +31,8 @@ oauth = { return models.User.check({email: username, password: password}).then(function (user) { //Everything validated, return the access- and refreshtoken - var accessToken = uid(256), - refreshToken = uid(256), + var accessToken = utils.uid(256), + refreshToken = utils.uid(256), accessExpires = Date.now() + 3600 * 1000, refreshExpires = Date.now() + 3600 * 24 * 1000; @@ -95,7 +61,7 @@ oauth = { return done(null, false); } else { var token = model.toJSON(), - accessToken = uid(256), + accessToken = utils.uid(256), accessExpires = Date.now() + 3600 * 1000; if (token.expires > Date.now()) { diff --git a/core/server/models/user.js b/core/server/models/user.js index e2654142e0..ec827f567a 100644 --- a/core/server/models/user.js +++ b/core/server/models/user.js @@ -116,7 +116,6 @@ User = ghostBookshelf.Model.extend({ * **See:** [ghostBookshelf.Model.add](base.js.html#Add) */ add: function (data, options) { - var self = this, // Clone the _user so we don't expose the hashed password unnecessarily userData = this.filterData(data); @@ -130,11 +129,6 @@ User = ghostBookshelf.Model.extend({ */ return validatePasswordLength(userData.password).then(function () { 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 () { // Generate a new password hash 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 userData = addedUser; - // Add this user to the admin role (assumes admin = role_id: 1) - return userData.roles().attach(1); + if (!data.role) { + // TODO: needs change when owner role is introduced and setup is changed + data.role = 1; + } + return userData.roles().attach(data.role); }).then(function (addedUserRole) { /*jshint unused:false*/ // find and return the added user 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) { @@ -233,8 +212,11 @@ User = ghostBookshelf.Model.extend({ check: function (object) { var self = this, s; - 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') { return nodefn.call(bcrypt.compare, object.password, user.get('password')).then(function (matched) { if (!matched) { @@ -296,6 +278,10 @@ User = ghostBookshelf.Model.extend({ generateResetToken: function (email, expires, dbHash) { return this.getByEmail(email).then(function (foundUser) { + if (!foundUser) { + return when.reject(new Error('NotFound')); + } + var hash = crypto.createHash('sha256'), text = ""; @@ -398,8 +384,8 @@ User = ghostBookshelf.Model.extend({ gravatarLookup: function (userData) { var gravatarUrl = '//www.gravatar.com/avatar/' + - crypto.createHash('md5').update(userData.email.toLowerCase().trim()).digest('hex') + - "?d=404", + crypto.createHash('md5').update(userData.email.toLowerCase().trim()).digest('hex') + + "?d=404", checkPromise = when.defer(); http.get('http:' + gravatarUrl, function (res) { @@ -427,12 +413,9 @@ User = ghostBookshelf.Model.extend({ var userWithEmail = users.find(function (user) { return user.get('email').toLowerCase() === email.toLowerCase(); }); - if (userWithEmail) { return when.resolve(userWithEmail); } - - return when.reject(new Error('NotFound')); }); } }); diff --git a/core/server/routes/api.js b/core/server/routes/api.js index 186b1e46a1..6eff8b6c84 100644 --- a/core/server/routes/api.js +++ b/core/server/routes/api.js @@ -22,6 +22,7 @@ apiRoutes = function (middleware) { 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/: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)); // ## Tags diff --git a/core/server/utils/index.js b/core/server/utils/index.js new file mode 100644 index 0000000000..29018df20d --- /dev/null +++ b/core/server/utils/index.js @@ -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; \ No newline at end of file diff --git a/core/test/integration/model/model_users_spec.js b/core/test/integration/model/model_users_spec.js index 40d4bdbfb1..54bd2cfb60 100644 --- a/core/test/integration/model/model_users_spec.js +++ b/core/test/integration/model/model_users_spec.js @@ -150,15 +150,6 @@ describe('User Model', function run() { }).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) { UserModel.findAll().then(function (results) {