From fe13503470ba0fe515bc2546b1b275139660fc0e Mon Sep 17 00:00:00 2001 From: Jason Williams Date: Sun, 6 Mar 2016 12:18:33 -0600 Subject: [PATCH] Refactor authentication API into pipeline format Refs #5508 --- core/server/api/authentication.js | 669 ++++++++++++------ core/server/errors/index.js | 2 + core/server/errors/token-revocation-error.js | 14 + core/server/models/base/token.js | 17 +- core/server/translations/en.json | 4 +- .../api/api_authentication_spec.js | 247 ++++++- 6 files changed, 711 insertions(+), 242 deletions(-) create mode 100644 core/server/errors/token-revocation-error.js diff --git a/core/server/api/authentication.js b/core/server/api/authentication.js index 05d9904c8e..b20f0c71da 100644 --- a/core/server/api/authentication.js +++ b/core/server/api/authentication.js @@ -1,54 +1,125 @@ var _ = require('lodash'), + validator = require('validator'), + pipeline = require('../utils/pipeline'), dataProvider = require('../models'), settings = require('./settings'), mail = require('./mail'), globalUtils = require('../utils'), utils = require('./utils'), - Promise = require('bluebird'), errors = require('../errors'), config = require('../config'), i18n = require('../i18n'), authentication; -function setupTasks(object) { - var setupUser, - internal = {context: {internal: true}}; - - return utils.checkObject(object, 'setup').then(function (checkedSetupData) { - setupUser = { - name: checkedSetupData.setup[0].name, - email: checkedSetupData.setup[0].email, - password: checkedSetupData.setup[0].password, - blogTitle: checkedSetupData.setup[0].blogTitle, - status: 'active' - }; - - return dataProvider.User.findOne({role: 'Owner', status: 'all'}); - }).then(function (ownerUser) { - if (ownerUser) { - return dataProvider.User.setup(setupUser, _.extend({id: ownerUser.id}, internal)); - } else { - return dataProvider.Role.findOne({name: 'Owner'}).then(function (ownerRole) { - setupUser.roles = [ownerRole.id]; - return dataProvider.User.add(setupUser, internal); - }); - } - }).then(function (user) { - var userSettings = []; - - // Handles the additional values set by the setup screen. - if (!_.isEmpty(setupUser.blogTitle)) { - userSettings.push({key: 'title', value: setupUser.blogTitle}); - userSettings.push({key: 'description', value: i18n.t('common.api.authentication.sampleBlogDescription')}); - } - - setupUser = user.toJSON(internal); - return settings.edit({settings: userSettings}, {context: {user: setupUser.id}}); - }).then(function () { - return Promise.resolve(setupUser); +/** + * Returns setup status + * + * @return {Promise} + */ +function checkSetup() { + return authentication.isSetup().then(function then(result) { + return result.setup[0].status; }); } +/** + * Allows an assertion to be made about setup status. + * + * @param {Boolean} status True: setup must be complete. False: setup must not be complete. + * @return {Function} returns a "task ready" function + */ +function assertSetupCompleted(status) { + return function checkPermission(__) { + return checkSetup().then(function then(isSetup) { + if (isSetup === status) { + return __; + } + + var completed = i18n.t('errors.api.authentication.setupAlreadyCompleted'), + notCompleted = i18n.t('errors.api.authentication.setupMustBeCompleted'); + + function throwReason(reason) { + throw new errors.NoPermissionError(reason); + } + + if (isSetup) { + throwReason(completed); + } else { + throwReason(notCompleted); + } + }); + }; +} + +function setupTasks(setupData) { + var tasks; + + function validateData(setupData) { + return utils.checkObject(setupData, 'setup').then(function then(checked) { + var data = checked.setup[0]; + + return { + name: data.name, + email: data.email, + password: data.password, + blogTitle: data.blogTitle, + status: 'active' + }; + }); + } + + function setupUser(userData) { + var context = {context: {internal: true}}, + User = dataProvider.User; + + return User.findOne({role: 'Owner', status: 'all'}).then(function then(owner) { + if (!owner) { + throw new errors.InternalServerError( + i18n.t('errors.api.authentication.setupUnableToRun') + ); + } + + return User.setup(userData, _.extend({id: owner.id}, context)); + }).then(function then(user) { + return { + user: user, + userData: userData + }; + }); + } + + function doSettings(data) { + var user = data.user, + blogTitle = data.userData.blogTitle, + context = {context: {user: data.user.id}}, + userSettings; + + if (!blogTitle || typeof blogTitle !== 'string') { + return user; + } + + userSettings = [ + {key: 'title', value: blogTitle.trim()}, + {key: 'description', value: i18n.t('common.api.authentication.sampleBlogDescription')} + ]; + + return settings.edit({settings: userSettings}, context).return(user); + } + + function formatResponse(user) { + return user.toJSON({context: {internal: true}}); + } + + tasks = [ + validateData, + setupUser, + doSettings, + formatResponse + ]; + + return pipeline(tasks, setupData); +} + /** * ## Authentication API Methods * @@ -57,266 +128,420 @@ function setupTasks(object) { authentication = { /** - * ## Generate Reset Token - * generate a reset token for a given email address - * @param {Object} object - * @returns {Promise(passwordreset)} message + * @description generate a reset token for a given email address + * @param {Object} resetRequest + * @returns {Promise} message */ - generateResetToken: function generateResetToken(object) { - var expires = Date.now() + globalUtils.ONE_DAY_MS, - email; + generateResetToken: function generateResetToken(resetRequest) { + var tasks; - return authentication.isSetup().then(function (result) { - var setup = result.setup[0].status; + function validateRequest(resetRequest) { + return utils.checkObject(resetRequest, 'passwordreset').then(function then(data) { + var email = data.passwordreset[0].email; - if (!setup) { - return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.authentication.setupMustBeCompleted'))); - } + if (typeof email !== 'string' || !validator.isEmail(email)) { + throw new errors.BadRequestError( + i18n.t('errors.api.authentication.noEmailProvided') + ); + } - return utils.checkObject(object, 'passwordreset'); - }).then(function (checkedPasswordReset) { - if (checkedPasswordReset.passwordreset[0].email) { - email = checkedPasswordReset.passwordreset[0].email; - } else { - return Promise.reject(new errors.BadRequestError(i18n.t('errors.api.authentication.noEmailProvided'))); - } + return email; + }); + } - 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, - resetUrl = baseUrl.replace(/\/$/, '') + '/ghost/reset/' + globalUtils.encodeBase64URLsafe(resetToken) + '/'; + function generateToken(email) { + var settingsQuery = {context: {internal: true}, key: 'dbHash'}; - return mail.generateContent({data: {resetUrl: resetUrl}, template: 'reset-password'}); - }).then(function (emailContent) { + return settings.read(settingsQuery).then(function then(response) { + var dbHash = response.settings[0].value, + expiresAt = Date.now() + globalUtils.ONE_DAY_MS; + + return dataProvider.User.generateResetToken(email, expiresAt, dbHash); + }).then(function then(resetToken) { + return { + email: email, + resetToken: resetToken + }; + }); + } + + function sendResetNotification(data) { + var baseUrl = config.forceAdminSSL ? (config.urlSSL || config.url) : config.url, + resetUrl = baseUrl.replace(/\/$/, '') + + '/ghost/reset/' + + globalUtils.encodeBase64URLsafe(data.resetToken) + '/'; + + return mail.generateContent({ + data: { + resetUrl: resetUrl + }, + template: 'reset-password' + }).then(function then(content) { var payload = { mail: [{ message: { - to: email, + to: data.email, subject: i18n.t('common.api.authentication.mail.resetPassword'), - html: emailContent.html, - text: emailContent.text + html: content.html, + text: content.text }, options: {} }] }; + return mail.send(payload, {context: {internal: true}}); - }).then(function () { - return Promise.resolve({passwordreset: [{message: i18n.t('common.api.authentication.mail.checkEmailForInstructions')}]}); - }).catch(function (error) { - return Promise.reject(error); }); - }); + } + + function formatResponse() { + return { + passwordreset: [ + {message: i18n.t('common.api.authentication.mail.checkEmailForInstructions')} + ] + }; + } + + tasks = [ + assertSetupCompleted(true), + validateRequest, + generateToken, + sendResetNotification, + formatResponse + ]; + + return pipeline(tasks, resetRequest); }, /** * ## Reset Password * reset password if a valid token and password (2x) is passed - * @param {Object} object - * @returns {Promise(passwordreset)} message + * @param {Object} resetRequest + * @returns {Promise} message */ - resetPassword: function resetPassword(object) { - var resetToken, - newPassword, - ne2Password; + resetPassword: function resetPassword(resetRequest) { + var tasks; - return authentication.isSetup().then(function (result) { - var setup = result.setup[0].status; + function validateRequest(resetRequest) { + return utils.checkObject(resetRequest, 'passwordreset'); + } - if (!setup) { - return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.authentication.setupMustBeCompleted'))); - } + function doReset(resetRequest) { + var settingsQuery = {context: {internal: true}, key: 'dbHash'}, + data = resetRequest.passwordreset[0], + resetToken = data.token, + newPassword = data.newPassword, + ne2Password = data.ne2Password; - return utils.checkObject(object, 'passwordreset'); - }).then(function (checkedPasswordReset) { - resetToken = checkedPasswordReset.passwordreset[0].token; - newPassword = checkedPasswordReset.passwordreset[0].newPassword; - ne2Password = checkedPasswordReset.passwordreset[0].ne2Password; - - return settings.read({context: {internal: true}, key: 'dbHash'}).then(function (response) { - var dbHash = response.settings[0].value; + return settings.read(settingsQuery).then(function then(response) { return dataProvider.User.resetPassword({ token: resetToken, newPassword: newPassword, ne2Password: ne2Password, - dbHash: dbHash + dbHash: response.settings[0].value }); - }).then(function () { - return Promise.resolve({passwordreset: [{message: i18n.t('common.api.authentication.mail.passwordChanged')}]}); }).catch(function (error) { - return Promise.reject(new errors.UnauthorizedError(error.message)); + throw new errors.UnauthorizedError(error.message); }); - }); + } + + function formatResponse() { + return { + passwordreset: [ + {message: i18n.t('common.api.authentication.mail.passwordChanged')} + ] + }; + } + + tasks = [ + assertSetupCompleted(true), + validateRequest, + doReset, + formatResponse + ]; + + return pipeline(tasks, resetRequest); }, /** * ### Accept Invitation - * @param {User} object the user to create - * @returns {Promise(User}} Newly created user + * @param {Object} invitation an invitation object + * @returns {Promise} */ - acceptInvitation: function acceptInvitation(object) { - var resetToken, - newPassword, - ne2Password, - name, - email; + acceptInvitation: function acceptInvitation(invitation) { + var tasks; - return authentication.isSetup().then(function (result) { - var setup = result.setup[0].status; + function validateInvitation(invitation) { + return utils.checkObject(invitation, 'invitation'); + } - if (!setup) { - return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.authentication.setupMustBeCompleted'))); - } + function processInvitation(invitation) { + var User = dataProvider.User, + settingsQuery = {context: {internal: true}, key: 'dbHash'}, + data = invitation.invitation[0], + resetToken = data.token, + newPassword = data.password, + email = data.email, + name = data.name; - return utils.checkObject(object, 'invitation'); - }).then(function (checkedInvitation) { - resetToken = checkedInvitation.invitation[0].token; - newPassword = checkedInvitation.invitation[0].password; - ne2Password = checkedInvitation.invitation[0].password; - email = checkedInvitation.invitation[0].email; - name = checkedInvitation.invitation[0].name; - - return settings.read({context: {internal: true}, key: 'dbHash'}).then(function (response) { - var dbHash = response.settings[0].value; - return dataProvider.User.resetPassword({ + return settings.read(settingsQuery).then(function then(response) { + return User.resetPassword({ token: resetToken, newPassword: newPassword, - ne2Password: ne2Password, - dbHash: dbHash + ne2Password: newPassword, + dbHash: response.settings[0].value }); - }).then(function (user) { - // Setting the slug to '' has the model regenerate the slug from the user's name - return dataProvider.User.edit({name: name, email: email, slug: ''}, {id: user.id}); - }).then(function () { - return Promise.resolve({invitation: [{message: i18n.t('common.api.authentication.mail.invitationAccepted')}]}); + }).then(function then(user) { + return User.edit({name: name, email: email, slug: ''}, {id: user.id}); }).catch(function (error) { - return Promise.reject(new errors.UnauthorizedError(error.message)); + throw new errors.UnauthorizedError(error.message); }); - }); + } + + function formatResponse() { + return { + invitation: [ + {message: i18n.t('common.api.authentication.mail.invitationAccepted')} + ] + }; + } + + tasks = [ + assertSetupCompleted(true), + validateInvitation, + processInvitation, + formatResponse + ]; + + return pipeline(tasks, invitation); }, /** * ### Check for invitation * @param {Object} options - * @param {string} options.email The email to check for an invitation on - * @returns {Promise(Invitation}} An invitation status + * @returns {Promise} An invitation status */ isInvitation: function isInvitation(options) { - return authentication.isSetup().then(function (result) { - var setup = result.setup[0].status; + var tasks, + localOptions = _.cloneDeep(options || {}); - if (!setup) { - return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.authentication.setupMustBeCompleted'))); + function processArgs(options) { + var email = options.email; + + if (typeof email !== 'string' || !validator.isEmail(email)) { + throw new errors.BadRequestError( + i18n.t('errors.api.authentication.invalidEmailReceived') + ); } - if (options.email) { - return dataProvider.User.findOne({email: options.email, status: 'invited'}).then(function (response) { - if (response) { - return {invitation: [{valid: true}]}; - } else { - return {invitation: [{valid: false}]}; - } + return email; + } + + function checkInvitation(email) { + return dataProvider.User + .where({email: email, status: 'invited'}) + .count('id') + .then(function then(count) { + return !!count; }); - } else { - return Promise.reject(new errors.BadRequestError(i18n.t('errors.api.authentication.invalidEmailReceived'))); - } - }); + } + + function formatResponse(isInvited) { + return {invitation: [{valid: isInvited}]}; + } + + tasks = [ + processArgs, + assertSetupCompleted(true), + checkInvitation, + formatResponse + ]; + + return pipeline(tasks, localOptions); }, + /** + * Checks the setup status + * @return {Promise} + */ isSetup: function isSetup() { - return dataProvider.User.query(function (qb) { - qb.whereIn('status', ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4', 'locked']); - }).fetch().then(function (users) { - if (users) { - return Promise.resolve({setup: [{status: true}]}); - } else { - return Promise.resolve({setup: [{status: false}]}); - } - }); + var tasks, + validStatuses = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4', 'locked']; + + function checkSetupStatus() { + return dataProvider.User + .where('status', 'in', validStatuses) + .count('id') + .then(function (count) { + return !!count; + }); + } + + function formatResponse(isSetup) { + return {setup: [{status: isSetup}]}; + } + + tasks = [ + checkSetupStatus, + formatResponse + ]; + + return pipeline(tasks); }, - setup: function setup(object) { - var setupUser; + /** + * Executes the setup tasks and sends an email to the owner + * @param {Object} setupDetails + * @return {Promise} a user api payload + */ + setup: function setup(setupDetails) { + var tasks; - return authentication.isSetup().then(function (result) { - var setup = result.setup[0].status; - - if (setup) { - return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.authentication.setupAlreadyCompleted'))); - } - - return setupTasks(object); - }).then(function (result) { - setupUser = result; + function doSetup(setupDetails) { + return setupTasks(setupDetails); + } + function sendNotification(setupUser) { var data = { ownerEmail: setupUser.email }; - return mail.generateContent({data: data, template: 'welcome'}); - }).then(function (emailContent) { - var message = { - to: setupUser.email, - subject: i18n.t('common.api.authentication.mail.yourNewGhostBlog'), - html: emailContent.html, - text: emailContent.text - }, - payload = { - mail: [{ - message: message, - options: {} - }] - }; + return mail.generateContent({data: data, template: 'welcome'}) + .then(function then(content) { + var message = { + to: setupUser.email, + subject: i18n.t('common.api.authentication.mail.yourNewGhostBlog'), + html: content.html, + text: content.text + }, + payload = { + mail: [{ + message: message, + options: {} + }] + }; - mail.send(payload, {context: {internal: true}}).catch(function (error) { - errors.logError( - error.message, - i18n.t('errors.api.authentication.unableToSendWelcomeEmail', {url: 'http://support.ghost.org/mail/'}), - i18n.t('errors.api.authentication.checkEmailConfigInstructions') - ); - }); - }).then(function () { - return Promise.resolve({users: [setupUser]}); - }); - }, - - updateSetup: function updateSetup(object, options) { - if (!options.context || !options.context.user) { - return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.authentication.notLoggedIn'))); + mail.send(payload, {context: {internal: true}}).catch(function (error) { + errors.logError( + error.message, + i18n.t( + 'errors.api.authentication.unableToSendWelcomeEmail', + {url: 'http://support.ghost.org/mail/'} + ), + i18n.t('errors.api.authentication.checkEmailConfigInstructions') + ); + }); + }) + .return(setupUser); } - return dataProvider.User.findOne({role: 'Owner', status: 'all'}).then(function (result) { - var user = result.toJSON(); + function formatResponse(setupUser) { + return {users: [setupUser]}; + } - if (user.id !== options.context.user) { - return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.authentication.notTheBlogOwner'))); + tasks = [ + assertSetupCompleted(false), + doSetup, + sendNotification, + formatResponse + ]; + + return pipeline(tasks, setupDetails); + }, + + /** + * Updates the blog setup + * @param {Object} setupDetails request payload with setup details + * @param {Object} options + * @return {Promise} a User API response payload + */ + updateSetup: function updateSetup(setupDetails, options) { + var tasks, + localOptions = _.cloneDeep(options || {}); + + function processArgs(setupDetails, options) { + if (!options.context || !options.context.user) { + throw new errors.NoPermissionError(i18n.t('errors.api.authentication.notTheBlogOwner')); } - return setupTasks(object); - }).then(function (result) { - return Promise.resolve({users: [result]}); - }); - }, - - revoke: function (object) { - var token; - - if (object.token_type_hint && object.token_type_hint === 'access_token') { - token = dataProvider.Accesstoken; - } else if (object.token_type_hint && object.token_type_hint === 'refresh_token') { - token = dataProvider.Refreshtoken; - } else { - return errors.BadRequestError(i18n.t('errors.api.authentication.invalidTokenTypeHint')); + return _.assign({setupDetails: setupDetails}, options); } - return token.destroyByToken({token: object.token}).then(function () { - return Promise.resolve({token: object.token}); - }, function () { - // On error we still want a 200. See https://tools.ietf.org/html/rfc7009#page-5 - return Promise.resolve({token: object.token, error: i18n.t('errors.api.authentication.invalidTokenProvided')}); - }); + function checkPermission(options) { + return dataProvider.User.findOne({role: 'Owner', status: 'all'}) + .then(function (owner) { + if (owner.id !== options.context.user) { + throw new errors.NoPermissionError(i18n.t('errors.api.authentication.notTheBlogOwner')); + } + + return options.setupDetails; + }); + } + + function formatResponse(user) { + return {users: [user]}; + } + + tasks = [ + processArgs, + assertSetupCompleted(true), + checkPermission, + setupTasks, + formatResponse + ]; + + return pipeline(tasks, setupDetails, localOptions); + }, + + /** + * Revokes a bearer token. + * @param {Object} tokenDetails + * @param {Object} options + * @return {Promise} an object containing the revoked token. + */ + revoke: function revokeToken(tokenDetails, options) { + var tasks, + localOptions = _.cloneDeep(options || {}); + + function processArgs(tokenDetails, options) { + return _.assign({}, tokenDetails, options); + } + + function revokeToken(options) { + var providers = [ + dataProvider.Refreshtoken, + dataProvider.Accesstoken + ], + response = {token: options.token}; + + function destroyToken(provider, options, providers) { + return provider.destroyByToken(options) + .return(response) + .catch(provider.NotFoundError, function () { + if (!providers.length) { + return { + token: tokenDetails.token, + error: i18n.t('errors.api.authentication.invalidTokenProvided') + }; + } + + return destroyToken(providers.pop(), options, providers); + }) + .catch(function () { + throw new errors.TokenRevocationError( + i18n.t('errors.api.authentication.tokenRevocationFailed') + ); + }); + } + + return destroyToken(providers.pop(), options, providers); + } + + tasks = [ + processArgs, + revokeToken + ]; + + return pipeline(tasks, tokenDetails, localOptions); } }; diff --git a/core/server/errors/index.js b/core/server/errors/index.js index f7f49c3f14..3cb8711e25 100644 --- a/core/server/errors/index.js +++ b/core/server/errors/index.js @@ -17,6 +17,7 @@ var _ = require('lodash'), EmailError = require('./email-error'), DataImportError = require('./data-import-error'), TooManyRequestsError = require('./too-many-requests-error'), + TokenRevocationError = require('./token-revocation-error'), i18n = require('../i18n'), config, errors, @@ -431,3 +432,4 @@ module.exports.EmailError = EmailError; module.exports.DataImportError = DataImportError; module.exports.MethodNotAllowedError = MethodNotAllowedError; module.exports.TooManyRequestsError = TooManyRequestsError; +module.exports.TokenRevocationError = TokenRevocationError; diff --git a/core/server/errors/token-revocation-error.js b/core/server/errors/token-revocation-error.js new file mode 100644 index 0000000000..445db3732d --- /dev/null +++ b/core/server/errors/token-revocation-error.js @@ -0,0 +1,14 @@ +// # Token Revocation ERror +// Custom error class with status code and type prefilled. + +function TokenRevocationError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 503; + this.errorType = this.name; +} + +TokenRevocationError.prototype = Object.create(Error.prototype); +TokenRevocationError.prototype.name = 'TokenRevocationError'; + +module.exports = TokenRevocationError; diff --git a/core/server/models/base/token.js b/core/server/models/base/token.js index d383beb60b..c397125b4d 100644 --- a/core/server/models/base/token.js +++ b/core/server/models/base/token.js @@ -68,17 +68,14 @@ Basetoken = ghostBookshelf.Model.extend({ var token = options.token; options = this.filterOptions(options, 'destroyByUser'); + options.require = true; - if (token) { - return ghostBookshelf.Collection.forge([], {model: this}) - .query('where', 'token', '=', token) - .fetch(options) - .then(function then(collection) { - collection.invokeThen('destroy', options); - }); - } - - return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.base.token.tokenNotFound'))); + return this.forge() + .query('where', 'token', '=', token) + .fetch(options) + .then(function then(model) { + return model.destroy(options); + }); } }); diff --git a/core/server/translations/en.json b/core/server/translations/en.json index 8a4f81314b..f24774a853 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -278,6 +278,7 @@ }, "api": { "authentication": { + "setupUnableToRun": "Database missing fixture data. Please reset database and try again.", "setupMustBeCompleted": "Setup must be completed before making this request.", "noEmailProvided": "No email provided.", "invalidEmailReceived": "The server did not receive a valid email", @@ -287,7 +288,8 @@ "notLoggedIn": "You are not logged in.", "notTheBlogOwner": "You are not the blog owner.", "invalidTokenTypeHint": "Invalid token_type_hint given.", - "invalidTokenProvided": "Invalid token provided" + "invalidTokenProvided": "Invalid token provided", + "tokenRevocationFailed": "Token revocation failed" }, "clients": { "clientNotFound": "Client not found." diff --git a/core/test/integration/api/api_authentication_spec.js b/core/test/integration/api/api_authentication_spec.js index 193b80d843..c25b769b9e 100644 --- a/core/test/integration/api/api_authentication_spec.js +++ b/core/test/integration/api/api_authentication_spec.js @@ -3,6 +3,10 @@ var testUtils = require('../../utils'), should = require('should'), sinon = require('sinon'), Promise = require('bluebird'), + uid = require('../../../server/utils').uid, + Accesstoken, + Refreshtoken, + User, // Stuff we are testing @@ -51,6 +55,37 @@ describe('Authentication API', function () { should.exist(AuthAPI); describe('Setup', function () { + describe('Cannot run', function () { + before(function () { + User = require('../../../server/models/user').User; + }); + + beforeEach(testUtils.setup('roles', 'owner:pre', 'settings', 'perms:setting', 'perms:mail', 'perms:init')); + + describe('Invalid database state', function () { + it('should not allow setup to be run if owner missing from database', function (done) { + var setupData = { + name: 'test user', + email: 'test@example.com', + password: 'areallygoodpassword', + blogTitle: 'a test blog' + }; + + User.fetchAll().call('invokeThen', 'destroy').then(function () { + AuthAPI.setup({setup: [setupData]}).then(function () { + done(new Error('Setup ran when it should not have.')); + }).catch(function (err) { + should.exist(err); + err.name.should.equal('InternalServerError'); + err.statusCode.should.equal(500); + + done(); + }).catch(done); + }); + }); + }); + }); + describe('Not completed', function () { // TODO: stub settings beforeEach(testUtils.setup('roles', 'owner:pre', 'settings', 'perms:setting', 'perms:mail', 'perms:init')); @@ -89,6 +124,43 @@ describe('Authentication API', function () { }).catch(done); }); + it('should allow setup to be completed without a blog title', function (done) { + var setupData = { + name: 'test user', + email: 'test@example.com', + password: 'areallygoodpassword' + }; + + AuthAPI.setup({setup: [setupData]}).then(function (result) { + should.exist(result); + should.exist(result.users); + should.not.exist(result.meta); + result.users.should.have.length(1); + testUtils.API.checkResponse(result.users[0], 'user'); + + var newUser = result.users[0]; + + newUser.id.should.equal(1); + newUser.name.should.equal(setupData.name); + newUser.email.should.equal(setupData.email); + + done(); + }).catch(done); + }); + + it('should return an error for an invitation check', function (done) { + AuthAPI.isInvitation({email: 'a@example.com'}).then(function () { + done(new Error('Did not receive an error response')); + }).catch(function (err) { + should.exist(err); + + err.name.should.equal('NoPermissionError'); + err.statusCode.should.equal(403); + + done(); + }).catch(done); + }); + it('should not allow an invitation to be accepted', function (done) { AuthAPI.acceptInvitation(testInvite).then(function () { done(new Error('Invitation was allowed to be accepted')); @@ -99,7 +171,7 @@ describe('Authentication API', function () { err.statusCode.should.equal(403); done(); - }); + }).catch(done); }); it('should not generate a password reset token', function (done) { @@ -112,7 +184,7 @@ describe('Authentication API', function () { err.statusCode.should.equal(403); done(); - }); + }).catch(done); }); it('should not allow a password reset', function (done) { @@ -125,12 +197,18 @@ describe('Authentication API', function () { err.statusCode.should.equal(403); done(); - }); + }).catch(done); }); }); describe('Completed', function () { - beforeEach(testUtils.setup('roles', 'owner', 'settings', 'perms:setting', 'perms:mail', 'perms:init')); + before(function () { + Accesstoken = require('../../../server/models/accesstoken').Accesstoken; + Refreshtoken = require('../../../server/models/refreshtoken').Refreshtoken; + User = require('../../../server/models/user').User; + }); + + beforeEach(testUtils.setup('roles', 'owner', 'clients', 'settings', 'perms:setting', 'perms:mail', 'perms:init')); it('should report that setup has been completed', function (done) { AuthAPI.isSetup().then(function (result) { @@ -158,7 +236,7 @@ describe('Authentication API', function () { err.statusCode.should.equal(403); done(); - }); + }).catch(done); }); it('should allow an invitation to be accepted, but fail on token validation', function (done) { @@ -170,8 +248,9 @@ describe('Authentication API', function () { err.name.should.equal('UnauthorizedError'); err.statusCode.should.equal(401); err.message.should.equal('Invalid token structure'); + done(); - }); + }).catch(done); }); it('should generate a password reset token', function (done) { @@ -183,6 +262,23 @@ describe('Authentication API', function () { }).catch(done); }); + it('should not generate a password reset token for an invalid email address', function (done) { + var badResetRequest = { + passwordreset: [{email: ''}] + }; + + AuthAPI.generateResetToken(badResetRequest).then(function () { + done(new Error('reset token was generated for invalid email address')); + }).catch(function (err) { + should.exist(err); + + err.name.should.equal('BadRequestError'); + err.statusCode.should.equal(400); + + done(); + }).catch(done); + }); + it('should allow a password reset', function (done) { AuthAPI.resetPassword(testReset).then(function () { done(new Error('password reset did not fail on token validation')); @@ -192,8 +288,141 @@ describe('Authentication API', function () { err.name.should.equal('UnauthorizedError'); err.statusCode.should.equal(401); err.message.should.equal('Invalid token structure'); + done(); - }); + }).catch(done); + }); + + it('should allow an access token to be revoked', function (done) { + var id = uid(256); + + Accesstoken.add({ + token: id, + expires: Date.now() + 8640000, + user_id: 1, + client_id: 1 + }).then(function (token) { + should.exist(token); + token.get('token').should.equal(id); + + return AuthAPI.revoke({ + token: token.get('token'), + token_type_hint: 'access_token' + }); + }).then(function (response) { + should.exist(response); + response.token.should.equal(id); + + return Accesstoken.findOne({token: id}); + }).then(function (token) { + should.not.exist(token); + + done(); + }).catch(done); + }); + + it('should know an email address has an active invitation', function (done) { + var user = { + name: 'test user', + email: 'invited@example.com', + password: '12345678', + status: 'invited' + }, + options = { + context: {internal: true} + }; + + User.add(user, options).then(function (user) { + return AuthAPI.isInvitation({email: user.get('email')}); + }).then(function (response) { + should.exist(response); + response.invitation[0].valid.should.be.true(); + + done(); + }).catch(done); + }); + + it('should know an email address does not have an active invitation', function (done) { + var user = { + name: 'uninvited user', + email: 'notinvited@example.com', + password: '12345678', + status: 'active' + }, + options = { + context: {internal: true} + }; + + User.add(user, options).then(function (user) { + return AuthAPI.isInvitation({email: user.get('email')}); + }).then(function (response) { + should.exist(response); + response.invitation[0].valid.should.be.false(); + + done(); + }).catch(done); + }); + + it('should know an unknown email address is not an active invitation', function (done) { + AuthAPI.isInvitation({email: 'unknown@example.com'}).then(function (response) { + should.exist(response); + response.invitation[0].valid.should.be.false(); + + done(); + }).catch(done); + }); + + it('should allow a refresh token to be revoked', function (done) { + var id = uid(256); + + Refreshtoken.add({ + token: id, + expires: Date.now() + 8640000, + user_id: 1, + client_id: 1 + }).then(function (token) { + should.exist(token); + token.get('token').should.equal(id); + + return AuthAPI.revoke({ + token: token.get('token'), + token_type_hint: 'refresh_token' + }); + }).then(function (response) { + should.exist(response); + response.token.should.equal(id); + + return Refreshtoken.findOne({token: id}); + }).then(function (token) { + should.not.exist(token); + + done(); + }).catch(done); + }); + + it('should return success when attempting to revoke an invalid token', function (done) { + var id = uid(256); + + Accesstoken.add({ + token: id, + expires: Date.now() + 8640000, + user_id: 1, + client_id: 1 + }).then(function (token) { + should.exist(token); + token.get('token').should.equal(id); + + return AuthAPI.revoke({ + token: 'notavalidtoken', + token_type_hint: 'access_token' + }); + }).then(function (response) { + should.exist(response); + response.token.should.equal('notavalidtoken'); + response.error.should.equal('Invalid token provided'); + + done(); + }).catch(done); }); }); }); @@ -228,7 +457,7 @@ describe('Authentication API', function () { err.statusCode.should.equal(403); done(); - }); + }).catch(done); }); }); @@ -261,7 +490,7 @@ describe('Authentication API', function () { err.statusCode.should.equal(403); done(); - }); + }).catch(done); }); });