Remote method /user/:id/verify

This commit adds:
- user.prototype.verify(verifyOptions, options, cb)
- remote method /user/:id/verify
- User.getVerifyOptions()

The remote method can be used to replay the sending of a user
identity/email verification message.

`getVerifyOptions()` can be fully customized programmatically
or partially customized using user model's `.settings.verifyOptions`

`getVerifyOptions()` is called under the hood when calling the
/user/:id/verify remote method

`getVerifyOptions()` can also be used to ease the building
of identity verifyOptions:

```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.login()` has been modified to return the userId when
failing due to unverified identity/email. This userId can then be used
to call the /user/:id/verify remote method.
This commit is contained in:
ebarault 2017-03-28 11:10:51 +02:00
parent b96605c63a
commit b9fbf51b27
3 changed files with 279 additions and 70 deletions

View File

@ -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},

View File

@ -70,6 +70,13 @@
"permission": "ALLOW",
"property": "replaceById"
},
{
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "verify",
"accessType": "EXECUTE"
},
{
"principalType": "ROLE",
"principalId": "$everyone",

View File

@ -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() {