From be72ebf2a1c83ffeae2de17ef668d7326fba1b0e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 5 Jan 2017 17:01:41 -0700 Subject: [PATCH] Added tests + model implementation. --- client/coral-framework/actions/user.js | 2 +- models/setting.js | 3 +- models/user.js | 125 +++++++++++++++++++++---- routes/api/account/index.js | 53 ++++++++--- tests/models/action.js | 1 - tests/models/user.js | 68 ++++++++++++++ tests/routes/api/auth/index.js | 91 ++++++++++++------ 7 files changed, 279 insertions(+), 64 deletions(-) diff --git a/client/coral-framework/actions/user.js b/client/coral-framework/actions/user.js index b4b7b7896..208fc8f5d 100644 --- a/client/coral-framework/actions/user.js +++ b/client/coral-framework/actions/user.js @@ -14,7 +14,7 @@ const saveBioFailure = error => ({type: actions.SAVE_BIO_FAILURE, error}); export const saveBio = (user_id, formData) => dispatch => { dispatch(saveBioRequest()); - coralApi('/account/bio', {method: 'PUT', body: formData}) + coralApi('/account/settings', {method: 'PUT', body: formData}) .then(() => { dispatch(addNotification('success', lang.t('successBioUpdate'))); dispatch(saveBioSuccess(formData)); diff --git a/models/setting.js b/models/setting.js index 9e71e36aa..ad9fa6599 100644 --- a/models/setting.js +++ b/models/setting.js @@ -101,7 +101,8 @@ SettingSchema.method('filterForUser', function(user = false) { 'closeTimeout', 'closedMessage', 'charCountEnable', - 'charCount' + 'charCount', + 'requireEmailConfirmation' ]); } diff --git a/models/user.js b/models/user.js index 0f8fa3e79..5a0e7a640 100644 --- a/models/user.js +++ b/models/user.js @@ -6,6 +6,9 @@ const jwt = require('jsonwebtoken'); const Action = require('./action'); const Comment = require('./comment'); +const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; +const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; + // SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run // through during the salting process. const SALT_ROUNDS = 10; @@ -526,27 +529,45 @@ UserService.createPasswordResetToken = function (email) { version: user.__v }; - return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {algorithm: 'HS256'}, { - expiresIn: '1d' + return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { + algorithm: 'HS256', + expiresIn: '1d', + subject: PASSWORD_RESET_JWT_SUBJECT }); }); }; /** - * Verifies a jwt and returns the associated user. - * @param {String} token the JSON Web Token to verify + * Verifies that the token was indeed signed by the session secret. + * @param {String} token JWT token from the client + * @return {Promise} */ -UserService.verifyPasswordResetToken = token => { +UserService.verifyToken = (token, options = {}) => { return new Promise((resolve, reject) => { - jwt.verify(token, process.env.TALK_SESSION_SECRET, (err, decoded) => { + + // Set the allowed algorithms. + options.algorithms = ['HS256']; + + jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => { if (err) { return reject(err); } resolve(decoded); }); - }) - .then(decoded => UserService.findById(decoded.userId)); + }); +}; + +/** + * Verifies a jwt and returns the associated user. + * @param {String} token the JSON Web Token to verify + */ +UserService.verifyPasswordResetToken = (token) => { + return UserService + .verifyToken(token, { + subject: PASSWORD_RESET_JWT_SUBJECT + }) + .then((decoded) => UserService.findById(decoded.userId)); }; /** @@ -587,27 +608,23 @@ UserService.search = (value) => { * Returns a count of the current users. * @return {Promise} */ -UserService.count = () => { - return UserModel.count(); -}; +UserService.count = () => UserModel.count(); /** * Returns all the users. * @return {Promise} */ -UserService.all = () => { - return UserModel.find(); -}; +UserService.all = () => UserModel.find(); /** - * Adds a new User bio + * Updates the user's settings. * @return {Promise} */ -UserService.addBio = (id, bio) => UserModel.update({ +UserService.updateSettings = (id, settings) => UserModel.update({ id }, { $set: { - 'settings.bio': bio + settings } }); @@ -625,3 +642,77 @@ UserService.addAction = (item_id, user_id, action_type, metadata) => Action.inse action_type, metadata }); + +/** + * This creates a token based around confirming the local profile. + * @param {String} userID The user id for the user that we are creating the + * token for. + * @param {String} email The email that we are needing to get confirmed. + * @return {Promise} + */ +UserService.createEmailConfirmToken = (userID, email) => { + if (!email || typeof email !== 'string') { + return Promise.reject('email is required when creating a JWT for resetting passord'); + } + + email = email.toLowerCase(); + + return UserService + .findById(userID) + .then((user) => { + if (!user) { + return Promise.reject(new Error('user not found')); + } + + // Get the profile representing the local account. + let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local'); + + // Ensure that the user email hasn't already been verified. + if (profile && profile.metadata && profile.metadata.confirmed_at) { + return Promise.reject(new Error('email address already confirmed')); + } + + const payload = { + email, + userID + }; + + return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { + jwtid: uuid.v4(), + algorithm: 'HS256', + expiresIn: '1d', + subject: EMAIL_CONFIRM_JWT_SUBJECT + }); + }); +}; + +/** + * This verifies that a given token was for the email confirmation and updates + * that user's profile with a 'confirmed_at' parameter with the current date. + * @param {String} token the token containing the email confirmation details + * signed with our secret. + * @return {Promise} + */ +UserService.verifyEmailConfirmation = (token) => { + return UserService + .verifyToken(token, { + subject: EMAIL_CONFIRM_JWT_SUBJECT + }) + .then(({userID, email}) => { + + return UserModel + .update({ + id: userID, + profiles: { + $elemMatch: { + id: email, + provider: 'local' + } + } + }, { + $set: { + 'profiles.$.metadata.confirmed_at': new Date() + } + }); + }); +}; diff --git a/routes/api/account/index.js b/routes/api/account/index.js index b975ba284..645b9c652 100644 --- a/routes/api/account/index.js +++ b/routes/api/account/index.js @@ -4,10 +4,46 @@ const User = require('../../../models/user'); const mailer = require('../../../services/mailer'); const authorization = require('../../../middleware/authorization'); +// ErrPasswordTooShort is returned when the password length is too short. +const ErrPasswordTooShort = new Error('password must be at least 8 characters'); +ErrPasswordTooShort.status = 400; + +// ErrMissingToken is returned in the event that the password reset is requested +// without a token. +const ErrMissingToken = new Error('token is required'); +ErrMissingToken.status = 400; + +//============================================================================== +// ROUTES +//============================================================================== + router.get('/', authorization.needed(), (req, res, next) => { res.json(req.user); }); +// POST /email/confirm takes the password confirmation token available as a +// payload parameter and if it verifies, it updates the confirmed_at date on the +// local profile. +router.post('/email/confirm', (req, res, next) => { + + const { + token + } = req.body; + + if (!token) { + return next(ErrMissingToken); + } + + User + .verifyEmailConfirmation(token) + .then(() => { + res.status(204).end(); + }) + .catch((err) => { + next(err); + }); +}); + /** * this endpoint takes an email (username) and checks if it belongs to a User account * if it does, create a JWT and send an email @@ -52,15 +88,6 @@ router.post('/password/reset', (req, res, next) => { }); }); -// ErrPasswordTooShort is returned when the password length is too short. -const ErrPasswordTooShort = new Error('password must be at least 8 characters'); -ErrPasswordTooShort.status = 400; - -// ErrMissingToken is returned in the event that the password reset is requested -// without a token. -const ErrMissingToken = new Error('token is required'); -ErrMissingToken.status = 400; - /** * expects 2 fields in the body of the request * 1) the token that was in the url of the email link {String} @@ -95,18 +122,14 @@ router.put('/password/reset', (req, res, next) => { }); }); -router.put('/bio', authorization.needed(), (req, res, next) => { +router.put('/settings', authorization.needed(), (req, res, next) => { const { bio } = req.body; - if (!bio) { - return next(new Error('You must submit a new bio')); - } - User - .addBio(req.user.id, bio) + .updateSettings(req.user.id, {bio}) .then(() => { res.status(204).end(); }) diff --git a/tests/models/action.js b/tests/models/action.js index 3af55a640..a1ebdc027 100644 --- a/tests/models/action.js +++ b/tests/models/action.js @@ -27,7 +27,6 @@ describe('models.Action', () => { item_type: 'comment' } ]).then((actions) => { - console.log('all created'); mockActions = actions; })); diff --git a/tests/models/user.js b/tests/models/user.js index 3883e2e42..7484880bf 100644 --- a/tests/models/user.js +++ b/tests/models/user.js @@ -80,6 +80,74 @@ describe('models.User', () => { }); + describe('#createEmailConfirmToken', () => { + + it('should create a token for a valid user', () => { + return User + .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) + .then((token) => { + expect(token).to.not.be.null; + }); + }); + + it('should not create a token for a user already verified', () => { + return User + .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) + .then((token) => { + expect(token).to.not.be.null; + + return User.verifyEmailConfirmation(token); + }) + .then(() => { + return User.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id); + }) + .catch((err) => { + expect(err).to.have.property('message', 'email address already confirmed'); + }); + }); + + }); + + describe('#verifyEmailConfirmation', () => { + + it('should correctly validate a valid token', () => { + return User + .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) + .then((token) => { + expect(token).to.not.be.null; + + return User.verifyEmailConfirmation(token); + }); + }); + + it('should correctly reject an invalid token', () => { + return User + .verifyEmailConfirmation('cats') + .catch((err) => { + expect(err).to.not.be.null; + }); + }); + + it('should update the user model when verification is complete', () => { + return User + .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) + .then((token) => { + expect(token).to.not.be.null; + + return User.verifyEmailConfirmation(token); + }) + .then(() => { + return User.findById(mockUsers[0].id); + }) + .then((user) => { + expect(user.profiles[0]).to.have.property('metadata'); + expect(user.profiles[0].metadata).to.have.property('confirmed_at'); + expect(user.profiles[0].metadata.confirmed_at).to.not.be.null; + }); + }); + + }); + describe('#setStatus', () => { it('should set the status to active', () => { return User diff --git a/tests/routes/api/auth/index.js b/tests/routes/api/auth/index.js index ca7b0d558..4848a94c7 100644 --- a/tests/routes/api/auth/index.js +++ b/tests/routes/api/auth/index.js @@ -20,40 +20,73 @@ describe('/api/v1/auth', () => { }); const Setting = require('../../../../models/setting'); -const settings = {id: '1'}; describe('/api/v1/auth/local', () => { - beforeEach(() => Promise.all([ - User.createLocalUser('maria@gmail.com', 'password!', 'Maria'), - Setting.init(settings) - ])); + let mockUser; + beforeEach(() => User.createLocalUser('maria@gmail.com', 'password!', 'Maria').then((user) => { + mockUser = user; + })); - describe('#post', () => { - it('should send back the user on a successful login', () => { - return chai.request(app) - .post('/api/v1/auth/local') - .send({email: 'maria@gmail.com', password: 'password!'}) - .then((res) => { - expect(res).to.have.status(200); - expect(res).to.be.json; - expect(res.body).to.have.property('user'); - expect(res.body.user).to.have.property('displayName', 'Maria'); - }) - .catch((err) => { - console.error(err); - }); - }); + describe('email confirmation disabled', () => { + + beforeEach(() => Setting.init({requireEmailConfirmation: false})); + + describe('#post', () => { + it('should send back the user on a successful login', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!'}) + .then((res) => { + expect(res).to.have.status(200); + expect(res).to.be.json; + expect(res.body).to.have.property('user'); + expect(res.body.user).to.have.property('displayName', 'Maria'); + }); + }); + + it('should not send back the user on a unsuccessful login', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!3'}) + .catch((err) => { + expect(err).to.not.be.null; + expect(err.response).to.have.status(401); + expect(err.response.body).to.have.property('message', 'not authorized'); + }); + }); - it('should not send back the user on a unsuccessful login', () => { - return chai.request(app) - .post('/api/v1/auth/local') - .send({email: 'maria@gmail.com', password: 'password!3'}) - .catch((err) => { - expect(err).to.not.be.null; - expect(err.response).to.have.status(401); - expect(err.response.body).to.have.property('message', 'not authorized'); - }); }); }); + + describe('email confirmation enabled', () => { + + beforeEach(() => Setting.init({requireEmailConfirmation: true})); + + describe('#post', () => { + it('should not allow a login from a user that is not confirmed', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!'}) + .catch((err) => { + err.response.should.have.status(401); + + return User.createEmailConfirmToken(mockUser.id, mockUser.profiles[0].id); + }) + .then(User.verifyEmailConfirmation) + .then(() => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!'}); + }) + .then((res) => { + expect(res).to.have.status(200); + expect(res).to.be.json; + expect(res.body).to.have.property('user'); + expect(res.body.user).to.have.property('displayName', 'Maria'); + }); + }); + }); + + }); });