diff --git a/common/models/user.js b/common/models/user.js index 34978af0..0f15a320 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -183,10 +183,16 @@ module.exports = function(User) { * * ```js * User.login({username: 'foo', password: 'bar'}, function (err, token) { - * console.log(token.id); - * }); + * console.log(token.id); + * }); * ``` * + * If the `emailVerificationRequired` flag is set for the inherited user model + * and the email has not yet been verified then the method will return a 401 + * error that will contain the user's id. This id can be used to call the + * `api/verify` remote method to generate a new email verification token and + * send back the related email to the user. + * * @param {Object} credentials username/password or email/password * @param {String[]|String} [include] Optionally set it to "user" to include * the user info @@ -273,6 +279,9 @@ module.exports = function(User) { err = new Error(g.f('login failed as the email has not been verified')); err.statusCode = 401; err.code = 'LOGIN_FAILED_EMAIL_NOT_VERIFIED'; + err.details = { + userId: user.id, + }; fn(err); } else { if (user.createAccessToken.length === 2) { @@ -527,42 +536,152 @@ module.exports = function(User) { }; /** - * Verify a user's identity by sending them a confirmation email. + * Returns default verification options to use when calling User.prototype.verify() + * from remote method /user/:id/verify. + * + * NOTE: the User.getVerifyOptions() method can also be used to ease the + * building of identity verification options. * * ```js - * var verifyOptions = { - * type: 'email', - * from: noreply@example.com, - * template: 'verify.ejs', - * redirect: '/', - * tokenGenerator: function (user, cb) { cb("random-token"); } - * }; + * var verifyOptions = MyUser.getVerifyOptions(); + * user.verify(verifyOptions); + * ``` * - * user.verify(verifyOptions, options, next); + * This is the full list of possible params, with example values + * + * ```js + * { + * type: 'email', + * mailer: { + * send(verifyOptions, options, cb) { + * // send the email + * cb(err, result); + * } + * }, + * to: 'test@email.com', + * from: 'noreply@email.com' + * subject: 'verification email subject', + * text: 'Please verify your email by opening this link in a web browser', + * headers: {'Mime-Version': '1.0'}, + * template: 'path/to/template.ejs', + * templateFn: function(verifyOptions, options, cb) { + * cb(null, 'some body template'); + * } + * redirect: '/', + * verifyHref: 'http://localhost:3000/api/user/confirm', + * host: 'localhost' + * protocol: 'http' + * port: 3000, + * restApiRoot= '/api', + * generateVerificationToken: function (user, options, cb) { + * cb(null, 'random-token'); + * } + * } + * ``` + * + * NOTE: param `to` internally defaults to user's email but can be overriden for + * test purposes or advanced customization. + * + * Static default params can be modified in your custom user model json definition + * using `settings.verifyOptions`. Any default param can be programmatically modified + * like follows: + * + * ```js + * customUserModel.getVerifyOptions = function() { + * const base = MyUser.base.getVerifyOptions(); + * return Object.assign({}, base, { + * // custom values + * }); + * } + * ``` + * + * Usually you should only require to modify a subset of these params + * See `User.verify()` and `User.prototype.verify()` doc for params reference + * and their default values. + */ + + User.getVerifyOptions = function() { + const verifyOptions = { + type: 'email', + from: 'noreply@example.com', + }; + return this.settings.verifyOptions || verifyOptions; + }; + + /** + * Verify a user's identity by sending them a confirmation message. + * NOTE: Currently only email verification is supported + * + * ```js + * var verifyOptions = { + * type: 'email', + * from: 'noreply@example.com' + * template: 'verify.ejs', + * redirect: '/', + * generateVerificationToken: function (user, options, cb) { + * cb('random-token'); + * } + * }; + * + * user.verify(verifyOptions); + * ``` + * + * NOTE: the User.getVerifyOptions() method can also be used to ease the + * building of identity verification options. + * + * ```js + * var verifyOptions = MyUser.getVerifyOptions(); + * user.verify(verifyOptions); * ``` * * @options {Object} verifyOptions - * @property {String} type Must be 'email'. + * @property {String} type Must be `'email'` in the current implementation. + * @property {Function} mailer A mailer function with a static `.send() method. + * The `.send()` method must accept the verifyOptions object, the method's + * remoting context options object and a callback function with `(err, email)` + * as parameters. + * Defaults to provided `userModel.email` function, or ultimately to LoopBack's + * own mailer function. * @property {String} to Email address to which verification email is sent. - * @property {String} from Sender email addresss, for example - * `'noreply@myapp.com'`. + * Defaults to user's email. Can also be overriden to a static value for test + * purposes. + * @property {String} from Sender email address + * For example `'noreply@example.com'`. * @property {String} subject Subject line text. + * Defaults to `'Thanks for Registering'` or a local equivalent. * @property {String} text Text of email. - * @property {String} template Name of template that displays verification - * page, for example, `'verify.ejs'. + * Defaults to `'Please verify your email by opening this link in a web browser:` + * followed by the verify link. + * @property {Object} headers Email headers. None provided by default. + * @property {String} template Relative path of template that displays verification + * page. Defaults to `'../../templates/verify.ejs'`. * @property {Function} templateFn A function generating the email HTML body - * from `verify()` options object and generated attributes like `options.verifyHref`. - * It must accept the option object and a callback function with `(err, html)` - * as parameters + * from `verify()` options object and generated attributes like `options.verifyHref`. + * It must accept the verifyOptions object, the method's remoting context options + * object and a callback function with `(err, html)` as parameters. + * A default templateFn function is provided, see `createVerificationEmailBody()` + * for implementation details. * @property {String} redirect Page to which user will be redirected after - * they verify their email, for example `'/'` for root URI. + * they verify their email. Defaults to `'/'`. + * @property {String} verifyHref The link to include in the user's verify message. + * Defaults to an url analog to: + * `http://host:port/restApiRoot/userRestPath/confirm?uid=userId&redirect=/`` + * @property {String} host The API host. Defaults to app's host or `localhost`. + * @property {String} protocol The API protocol. Defaults to `'http'`. + * @property {Number} port The API port. Defaults to app's port or `3000`. + * @property {String} restApiRoot The API root path. Defaults to app's restApiRoot + * or `'/api'` * @property {Function} generateVerificationToken A function to be used to - * generate the verification token. It must accept the user object and a - * callback function. This function should NOT add the token to the user - * object, instead simply execute the callback with the token! User saving - * and email sending will be handled in the `verify()` method. - * @param {Object} options remote context options. + * generate the verification token. + * It must accept the verifyOptions object, the method's remoting context options + * object and a callback function with `(err, hexStringBuffer)` as parameters. + * This function should NOT add the token to the user object, instead simply + * execute the callback with the token! User saving and email sending will be + * handled in the `verify()` method. + * A default token generation function is provided, see `generateVerificationToken()` + * for implementation details. * @callback {Function} cb Callback function. + * @param {Object} options remote context options. * @param {Error} err Error object. * @param {Object} object Contains email, token, uid. * @promise @@ -585,9 +704,11 @@ module.exports = function(User) { // Set a default template generation function if none provided verifyOptions.templateFn = verifyOptions.templateFn || createVerificationEmailBody; + // Set a default token generation function if none provided verifyOptions.generateVerificationToken = verifyOptions.generateVerificationToken || User.generateVerificationToken; + // Set a default mailer function if none provided verifyOptions.mailer = verifyOptions.mailer || userModel.email || registry.getModelByType(loopback.Email); @@ -654,10 +775,8 @@ module.exports = function(User) { function sendEmail(user) { verifyOptions.verifyHref += '&token=' + user.verificationToken; verifyOptions.verificationToken = user.verificationToken; - verifyOptions.text = verifyOptions.text || g.f('Please verify your email by opening ' + 'this link in a web browser:\n\t%s', verifyOptions.verifyHref); - verifyOptions.text = verifyOptions.text.replace(/\{href\}/g, verifyOptions.verifyHref); // argument "options" is passed depending on templateFn function requirements @@ -1003,10 +1122,22 @@ module.exports = function(User) { } ); + UserModel.remoteMethod( + 'prototype.verify', + { + description: 'Trigger user\'s identity verification with configured verifyOptions', + accepts: [ + {arg: 'verifyOptions', type: 'object', http: ctx => this.getVerifyOptions()}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ], + http: {verb: 'post'}, + } + ); + UserModel.remoteMethod( 'confirm', { - description: 'Confirm a user registration with email verification token.', + description: 'Confirm a user registration with identity verification token.', accepts: [ {arg: 'uid', type: 'string', required: true}, {arg: 'token', type: 'string', required: true}, diff --git a/common/models/user.json b/common/models/user.json index c0e4d66b..26bad168 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -70,6 +70,13 @@ "permission": "ALLOW", "property": "replaceById" }, + { + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW", + "property": "verify", + "accessType": "EXECUTE" + }, { "principalType": "ROLE", "principalId": "$everyone", diff --git a/test/user.test.js b/test/user.test.js index bb2375c1..9e54367c 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -854,7 +854,7 @@ describe('User', function() { User.settings.emailVerificationRequired = false; }); - it('Require valid and complete credentials for email verification error', function(done) { + it('requires valid and complete credentials for email verification', function(done) { User.login({email: validCredentialsEmail}, function(err, accessToken) { // strongloop/loopback#931 // error message should be "login failed" @@ -862,12 +862,15 @@ describe('User', function() { assert(err && !/verified/.test(err.message), 'expecting "login failed" error message, received: "' + err.message + '"'); assert.equal(err.code, 'LOGIN_FAILED'); + // as login is failing because of invalid credentials it should to return + // the user id in the error message + assert.equal(err.details, undefined); done(); }); }); - it('Require valid and complete credentials for email verification error - promise variant', + it('requires valid and complete credentials for email verification - promise variant', function(done) { User.login({email: validCredentialsEmail}) .then(function(accessToken) { @@ -879,34 +882,30 @@ describe('User', function() { assert(err && !/verified/.test(err.message), 'expecting "login failed" error message, received: "' + err.message + '"'); assert.equal(err.code, 'LOGIN_FAILED'); - + assert.equal(err.details, undefined); done(); }); }); - it('Login a user by without email verification', function(done) { - User.login(validCredentials, function(err, accessToken) { - assert(err); - assert.equal(err.code, 'LOGIN_FAILED_EMAIL_NOT_VERIFIED'); - - done(); - }); + it('does not login a user with unverified email but provides userId', function() { + return User.login(validCredentials).then( + function(user) { + throw new Error('User.login() should have failed'); + }, + function(err, accessToken) { + err = Object.assign({}, err); + expect(err).to.eql({ + statusCode: 401, + code: 'LOGIN_FAILED_EMAIL_NOT_VERIFIED', + details: { + userId: validCredentialsUser.pk, + }, + }); + } + ); }); - it('Login a user by without email verification - promise variant', function(done) { - User.login(validCredentials) - .then(function(err, accessToken) { - done(); - }) - .catch(function(err) { - assert(err); - assert.equal(err.code, 'LOGIN_FAILED_EMAIL_NOT_VERIFIED'); - - done(); - }); - }); - - it('Login a user by with email verification', function(done) { + it('login a user with verified email', function(done) { User.login(validCredentialsEmailVerified, function(err, accessToken) { assertGoodToken(accessToken, validCredentialsEmailVerifiedUser); @@ -914,7 +913,7 @@ describe('User', function() { }); }); - it('Login a user by with email verification - promise variant', function(done) { + it('login a user with verified email - promise variant', function(done) { User.login(validCredentialsEmailVerified) .then(function(accessToken) { assertGoodToken(accessToken, validCredentialsEmailVerifiedUser); @@ -926,7 +925,7 @@ describe('User', function() { }); }); - it('Login a user over REST when email verification is required', function(done) { + it('login a user over REST when email verification is required', function(done) { request(app) .post('/test-users/login') .expect('Content-Type', /json/) @@ -944,7 +943,7 @@ describe('User', function() { }); }); - it('Login user over REST require complete and valid credentials ' + + it('login user over REST require complete and valid credentials ' + 'for email verification error message', function(done) { request(app) @@ -967,7 +966,10 @@ describe('User', function() { }); }); - it('Login a user over REST without email verification when it is required', function(done) { + it('login a user over REST without email verification when it is required', function(done) { + // make sure the app is configured in production mode + app.set('remoting', {errorHandler: {debug: false, log: false}}); + request(app) .post('/test-users/login') .expect('Content-Type', /json/) @@ -977,7 +979,19 @@ describe('User', function() { if (err) return done(err); var errorResponse = res.body.error; - assert.equal(errorResponse.code, 'LOGIN_FAILED_EMAIL_NOT_VERIFIED'); + + // extracting code and details error response + let errorExcerpts = { + code: errorResponse.code, + details: errorResponse.details, + }; + + expect(errorExcerpts).to.eql({ + code: 'LOGIN_FAILED_EMAIL_NOT_VERIFIED', + details: { + userId: validCredentialsUser.pk, + }, + }); done(); }); @@ -1511,7 +1525,7 @@ describe('User', function() { } }); - describe('Verification', function() { + describe('Identity verification', function() { describe('user.verify(verifyOptions, options, cb)', function() { const ctxOptions = {testFlag: true}; let verifyOptions; @@ -1524,7 +1538,44 @@ describe('User', function() { }; }); - it('Verify a user\'s email address', function(done) { + describe('User.getVerifyOptions()', function() { + it('returns default verify options', function(done) { + const verifyOptions = User.getVerifyOptions(); + expect(verifyOptions).to.eql({ + type: 'email', + from: 'noreply@example.com', + }); + done(); + }); + + it('handles custom verify options defined via model.settings', function(done) { + User.settings.verifyOptions = { + type: 'email', + from: 'test@example.com', + }; + const verifyOptions = User.getVerifyOptions(); + expect(verifyOptions).to.eql(User.settings.verifyOptions); + done(); + }); + + it('can be extended by user', function(done) { + User.getVerifyOptions = function() { + const base = User.base.getVerifyOptions(); + return Object.assign({}, base, { + redirect: '/redirect', + }); + }; + const verifyOptions = User.getVerifyOptions(); + expect(verifyOptions).to.eql({ + type: 'email', + from: 'noreply@example.com', + redirect: '/redirect', + }); + done(); + }); + }); + + it('verifies a user\'s email address', function(done) { User.afterRemote('create', function(ctx, user, next) { assert(user, 'afterRemote should include result'); @@ -1550,7 +1601,7 @@ describe('User', function() { }); }); - it('Verify a user\'s email address - promise variant', function(done) { + it('verifies a user\'s email address - promise variant', function(done) { User.afterRemote('create', function(ctx, user, next) { assert(user, 'afterRemote should include result'); @@ -1580,7 +1631,7 @@ describe('User', function() { }); }); - it('Verify a user\'s email address with custom header', function(done) { + it('verifies a user\'s email address with custom header', function(done) { User.afterRemote('create', function(ctx, user, next) { assert(user, 'afterRemote should include result'); @@ -1604,7 +1655,7 @@ describe('User', function() { }); }); - it('Verify a user\'s email address with custom template function', function(done) { + it('verifies a user\'s email address with custom template function', function(done) { User.afterRemote('create', function(ctx, user, next) { assert(user, 'afterRemote should include result'); @@ -1674,7 +1725,7 @@ describe('User', function() { }); }); - it('Verify a user\'s email address with custom token generator', function(done) { + it('verifies a user\'s email address with custom token generator', function(done) { User.afterRemote('create', function(ctx, user, next) { assert(user, 'afterRemote should include result'); @@ -1711,7 +1762,7 @@ describe('User', function() { }); }); - it('Fails if custom token generator returns error', function(done) { + it('fails if custom token generator returns error', function(done) { User.afterRemote('create', function(ctx, user, next) { assert(user, 'afterRemote should include result'); @@ -1742,7 +1793,7 @@ describe('User', function() { }); describe('Verification link port-squashing', function() { - it('Do not squash non-80 ports for HTTP links', function(done) { + it('does not squash non-80 ports for HTTP links', function(done) { User.afterRemote('create', function(ctx, user, next) { assert(user, 'afterRemote should include result'); @@ -1769,7 +1820,7 @@ describe('User', function() { }); }); - it('Squash port 80 for HTTP links', function(done) { + it('squashes port 80 for HTTP links', function(done) { User.afterRemote('create', function(ctx, user, next) { assert(user, 'afterRemote should include result'); @@ -1796,7 +1847,7 @@ describe('User', function() { }); }); - it('Do not squash non-443 ports for HTTPS links', function(done) { + it('does not squash non-443 ports for HTTPS links', function(done) { User.afterRemote('create', function(ctx, user, next) { assert(user, 'afterRemote should include result'); @@ -1824,7 +1875,7 @@ describe('User', function() { }); }); - it('Squash port 443 for HTTPS links', function(done) { + it('squashes port 443 for HTTPS links', function(done) { User.afterRemote('create', function(ctx, user, next) { assert(user, 'afterRemote should include result'); @@ -1853,7 +1904,7 @@ describe('User', function() { }); }); - it('should hide verification tokens from user JSON', function(done) { + it('hides verification tokens from user JSON', function(done) { var user = new User({ email: 'bar@bat.com', password: 'bar', @@ -1865,7 +1916,7 @@ describe('User', function() { done(); }); - it('should squash "//" when restApiRoot is "/"', function(done) { + it('squashes "//" when restApiRoot is "/"', function(done) { var emailBody; User.afterRemote('create', function(ctx, user, next) { assert(user, 'afterRemote should include result'); @@ -2019,6 +2070,26 @@ describe('User', function() { return User.create({email: 'test@example.com', password: 'pass'}) .then(u => user = u); } + + it('is called over REST method /User/:id/verify', function() { + return User.create({email: 'bar@bat.com', password: 'bar'}) + .then(user => { + return request(app) + .post('/test-users/' + user.pk + '/verify') + .expect('Content-Type', /json/) + // we already tested before that User.verify(id) works correctly + // having the remote method returning 204 is enough to make sure + // User.verify() was called successfully + .expect(204); + }); + }); + + it('fails over REST method /User/:id/verify with invalid user id', function() { + return request(app) + .post('/test-users/' + 'invalid-id' + '/verify') + .expect('Content-Type', /json/) + .expect(404); + }); }); describe('User.confirm(options, fn)', function() {