From 42e538483d2797b53600bb9a8aeb800e0ce31066 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Fri, 20 May 2022 12:51:37 +0300 Subject: [PATCH 01/16] feat: contact email --- schemas/activate.json | 6 +++- schemas/contacts.add.json | 4 +++ schemas/contacts.challenge.json | 3 ++ schemas/contacts.verify-email.json | 13 +++++++ src/actions/activate.js | 9 ++++- src/actions/contacts/add.js | 8 ++--- src/actions/contacts/challenge.js | 5 +-- src/actions/contacts/verify-email.js | 27 +++++++++++++++ src/configs/emails.js | 3 ++ src/configs/verification-challenges.js | 1 + src/utils/challenges/email/generate.js | 2 ++ src/utils/contacts.js | 48 ++++++++++++++++++++++++-- 12 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 schemas/contacts.verify-email.json create mode 100644 src/actions/contacts/verify-email.js diff --git a/schemas/activate.json b/schemas/activate.json index 0a9c3e860..d669a3d27 100644 --- a/schemas/activate.json +++ b/schemas/activate.json @@ -29,6 +29,10 @@ "audience": { "type": "string", "minLength": 1 + }, + "shouldVerifyContact": { + "type": "boolean", + "default": false } } -} \ No newline at end of file +} diff --git a/schemas/contacts.add.json b/schemas/contacts.add.json index 1b4997c6c..27512dd9d 100644 --- a/schemas/contacts.add.json +++ b/schemas/contacts.add.json @@ -12,6 +12,10 @@ }, "contact": { "$ref": "common.json#/definitions/contact" + }, + "skipChallenge": { + "type": "boolean", + "default": false } } } diff --git a/schemas/contacts.challenge.json b/schemas/contacts.challenge.json index c61c37ec7..8d850058c 100644 --- a/schemas/contacts.challenge.json +++ b/schemas/contacts.challenge.json @@ -13,6 +13,9 @@ "contact": { "$ref": "common.json#/definitions/contact", "required": ["value"] + }, + "metadata": { + "type": "object" } } } diff --git a/schemas/contacts.verify-email.json b/schemas/contacts.verify-email.json new file mode 100644 index 000000000..f544df0bb --- /dev/null +++ b/schemas/contacts.verify-email.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "required": [ + "secret" + ], + "properties": { + "secret": { + "type": "string", + "minLength": 3, + "maxLength": 100 + } + } +} diff --git a/src/actions/activate.js b/src/actions/activate.js index 76ff2372f..d93f1a9c1 100644 --- a/src/actions/activate.js +++ b/src/actions/activate.js @@ -21,6 +21,7 @@ const { USERS_ACTION_ACTIVATE, USERS_ACTIVATED_FIELD, } = require('../constants'); +const { contacts } = require('../configs/contacts'); // cache error const Forbidden = new HttpStatusError(403, 'invalid token'); @@ -113,6 +114,7 @@ function verifyRequest() { /** * Activates account after it was verified * @param {Object} data internal user data + * @param {Object} metadata user metadata * @return {Promise} */ async function activateAccount(data, metadata) { @@ -188,11 +190,12 @@ function hook(userId) { * @apiParam (Payload) {String} [remoteip] - not used, but is reserved for security log in the future * @apiParam (Payload) {String} [audience] - additional metadata will be pushed there from custom hooks * @apiParam (Payload) {Boolean} [isStatelessAuth=false] - users Stateless JWT token flow + * @apiParam (Payload) {Boolean} [shouldVerifyContact=false] - users Stateless JWT token flow */ async function activateAction({ log, params }) { // TODO: add security logs // var remoteip = request.params.remoteip; - const { token, username, isStatelessAuth } = params; + const { token, username, isStatelessAuth, shouldVerifyContact } = params; const { config } = this; const { jwt: { defaultAudience } } = config; const audience = params.audience || defaultAudience; @@ -220,6 +223,10 @@ async function activateAction({ log, params }) { .spread(activateAccount) .tap(hook); + if (shouldVerifyContact) { + await contacts.setVerifiedIfExist({ redis: this.redis, userId, value: username || userId }); + } + return jwt.login.call(this, userId, audience, isStatelessAuth); } diff --git a/src/actions/contacts/add.js b/src/actions/contacts/add.js index bf5e2b93a..a7d90334f 100644 --- a/src/actions/contacts/add.js +++ b/src/actions/contacts/add.js @@ -12,13 +12,13 @@ const { getUserId } = require('../../utils/userData'); * * @apiParam (Payload) {String} username - */ -module.exports = async function add({ params }) { - const userId = await getUserId.call(this, params.username); - const contact = await contacts.add.call(this, { contact: params.contact, userId }); +module.exports = async function add({ params: { username, contact, skipChallenge } }) { + const userId = await getUserId.call(this, username); + const attributes = await contacts.add.call(this, { contact, userId, skipChallenge }); return { data: { - attributes: contact, + attributes, }, }; }; diff --git a/src/actions/contacts/challenge.js b/src/actions/contacts/challenge.js index bf4c0527a..108240aef 100644 --- a/src/actions/contacts/challenge.js +++ b/src/actions/contacts/challenge.js @@ -13,12 +13,13 @@ const { getUserId } = require('../../utils/userData'); * @apiParam (Payload) {String} username - */ module.exports = async function challenge({ params }) { + const { metadata: { i18nLocale } = {}, contact } = params; const userId = await getUserId.call(this, params.username); - const contact = await contacts.challenge.call(this, { contact: params.contact, userId }); + const attributes = await contacts.challenge.call(this, { contact, userId, i18nLocale }); return { data: { - attributes: contact, + attributes, }, }; }; diff --git a/src/actions/contacts/verify-email.js b/src/actions/contacts/verify-email.js new file mode 100644 index 000000000..bfb39c0c6 --- /dev/null +++ b/src/actions/contacts/verify-email.js @@ -0,0 +1,27 @@ +const { ActionTransport } = require('@microfleet/plugin-router'); + +const contacts = require('../../utils/contacts'); + +/** + * @api {amqp} .contacts.verificate + * @apiVersion 1.0.0 + * @apiName Add + * @apiGroup Users.Contact + * + * @apiDescription Interface request to verificate + * + * @apiParam (Payload) {String} secret - + */ +module.exports = async function verifyEmail({ secret }) { + const contact = await contacts.verifyEmail.call(this, { + token: secret, + }); + + return { + data: { + attributes: contact, + }, + }; +}; + +module.exports.transports = [ActionTransport.amqp, ActionTransport.internal]; diff --git a/src/configs/emails.js b/src/configs/emails.js index de1dc192f..4c9776dfb 100644 --- a/src/configs/emails.js +++ b/src/configs/emails.js @@ -7,6 +7,7 @@ exports.validation = { activate: '/activate', reset: '/reset', invite: '/register', + 'verify-contact': '/verify-contact', }, subjects: { activate: 'Activate your account', @@ -14,6 +15,7 @@ exports.validation = { password: 'Account Recovery', register: 'Account Registration', invite: 'Invitation to Register', + 'verify-contact': 'Verify your contact', }, senders: { activate: 'noreply ', @@ -21,6 +23,7 @@ exports.validation = { password: 'noreply ', register: 'noreply ', invite: 'noreply ', + 'verify-contact': 'noreply ', }, templates: { // specify template names here diff --git a/src/configs/verification-challenges.js b/src/configs/verification-challenges.js index b85062ea9..393c918cd 100644 --- a/src/configs/verification-challenges.js +++ b/src/configs/verification-challenges.js @@ -74,4 +74,5 @@ exports.phone = { */ exports.contacts = { max: 5, + onlyOneEmail: true, }; diff --git a/src/utils/challenges/email/generate.js b/src/utils/challenges/email/generate.js index 2229f96c3..68c9bbf9f 100644 --- a/src/utils/challenges/email/generate.js +++ b/src/utils/challenges/email/generate.js @@ -16,6 +16,7 @@ const { USERS_ACTION_ORGANIZATION_INVITE, USERS_ACTION_ORGANIZATION_REGISTER, USERS_ACTION_ORGANIZATION_ADD, + USERS_ACTION_VERIFY_CONTACT, } = require('../../../constants'); // will be replaced later @@ -38,6 +39,7 @@ function generate(email, type, ctx = {}, opts = {}, nodemailer = {}) { case USERS_ACTION_ACTIVATE: case USERS_ACTION_RESET: case USERS_ACTION_INVITE: + case USERS_ACTION_VERIFY_CONTACT: // generate secret context.qs = `?${stringify({ q: context.token.secret, diff --git a/src/utils/contacts.js b/src/utils/contacts.js index 4a6bcede0..55b41ea60 100644 --- a/src/utils/contacts.js +++ b/src/utils/contacts.js @@ -33,7 +33,17 @@ async function checkLimit({ userId }) { return contactsLength; } -async function add({ userId, contact }) { +async function setVerifiedIfExist({ redis, userId, value }) { + const key = redisKey(userId, USERS_CONTACTS, value); + const exist = await redis.hexists(key, 'verified'); + if (exist) { + await redis.hset(key, 'verified', true); + } + + return key; +} + +async function add({ userId, contact, skipChallenge }) { this.log.debug({ userId, contact }, 'add contact key params'); const { redis } = this; @@ -56,6 +66,10 @@ async function add({ userId, contact }) { pipe.set(redisKey(userId, USERS_DEFAULT_CONTACT), contact.value); } + if (skipChallenge) { + pipe.hset(key, 'verified', true); + } + await pipe.exec().then(handlePipeline); return contactData; @@ -80,7 +94,7 @@ async function list({ userId }) { return []; } -async function challenge({ userId, contact }) { +async function challenge({ userId, contact, i18nLocale }) { const { redis } = this; const key = redisKey(userId, USERS_CONTACTS, contact.value); @@ -92,10 +106,11 @@ async function challenge({ userId, contact }) { throttle, action: USERS_ACTION_VERIFY_CONTACT, id: contact.value, + metadata: { contact, userId }, ...this.config.token[contactData.type], }; - const { context } = await challengeAct.call(this, contactData.type, challengeOpts, contactData); + const { context } = await challengeAct.call(this, contactData.type, challengeOpts, { ...contactData, i18nLocale }); return { ...contact, @@ -103,6 +118,31 @@ async function challenge({ userId, contact }) { }; } +async function setAllEmailContactsOfUserAsUnVerified(redis, userId) { + const key = redisKey(userId, USERS_CONTACTS); + const contacts = await redis.smembers(key); + + if (contacts.length) { + const pipe = redis.pipeline(); + contacts.forEach((kkey) => pipe.hset(kkey, 'verified', false)); + await pipe.exec().then(handlePipeline); + } +} + +async function verifyEmail(secret) { + const { redis, tokenManager } = this; + const { metadata: { contact, userId } } = await tokenManager.verify(secret); + const key = redisKey(userId, USERS_CONTACTS, contact.value); + + if (this.config.contacts.onlyOneEmail) { + await setAllEmailContactsOfUserAsUnVerified(redis, userId); + } + + await redis.hset(key, 'verified', true); + + return redis.hgetall(key).then(parseObj); +} + async function verify({ userId, contact, token }) { const { redis, tokenManager } = this; const key = redisKey(userId, USERS_CONTACTS, contact.value); @@ -160,4 +200,6 @@ module.exports = { remove, list, checkLimit, + verifyEmail, + setVerifiedIfExist, }; From 5109f5e1b76d70ab0403096da284040cfd56dfa6 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Fri, 27 May 2022 08:17:37 +0300 Subject: [PATCH 02/16] feat: contact email --- schemas/contacts.challenge.json | 5 ++ schemas/contacts.verify-email.json | 2 +- src/actions/activate.js | 2 +- src/actions/contacts/verify-email.js | 6 +- src/configs/verification-challenges.js | 2 +- .../09-username-to-contact-email/index.js | 51 ++++++++++++++++ src/utils/contacts.js | 41 +++++++------ test/suites/actions/activate.js | 21 +++++++ test/suites/actions/contacts.js | 60 +++++++++++++++++++ 9 files changed, 164 insertions(+), 26 deletions(-) create mode 100644 src/migrations/09-username-to-contact-email/index.js diff --git a/schemas/contacts.challenge.json b/schemas/contacts.challenge.json index 8d850058c..46edcd366 100644 --- a/schemas/contacts.challenge.json +++ b/schemas/contacts.challenge.json @@ -14,6 +14,11 @@ "$ref": "common.json#/definitions/contact", "required": ["value"] }, + "i18nLocale": { + "type": "string", + "minLength": 1, + "maxLength": 10 + }, "metadata": { "type": "object" } diff --git a/schemas/contacts.verify-email.json b/schemas/contacts.verify-email.json index f544df0bb..e08180f0b 100644 --- a/schemas/contacts.verify-email.json +++ b/schemas/contacts.verify-email.json @@ -7,7 +7,7 @@ "secret": { "type": "string", "minLength": 3, - "maxLength": 100 + "maxLength": 1000 } } } diff --git a/src/actions/activate.js b/src/actions/activate.js index d93f1a9c1..de65fb59c 100644 --- a/src/actions/activate.js +++ b/src/actions/activate.js @@ -8,6 +8,7 @@ const { getInternalData } = require('../utils/userData'); const getMetadata = require('../utils/get-metadata'); const handlePipeline = require('../utils/pipeline-error'); const setMetadata = require('../utils/update-metadata'); +const contacts = require('../utils/contacts'); const { USERS_INDEX, USERS_DATA, @@ -21,7 +22,6 @@ const { USERS_ACTION_ACTIVATE, USERS_ACTIVATED_FIELD, } = require('../constants'); -const { contacts } = require('../configs/contacts'); // cache error const Forbidden = new HttpStatusError(403, 'invalid token'); diff --git a/src/actions/contacts/verify-email.js b/src/actions/contacts/verify-email.js index bfb39c0c6..beb79e249 100644 --- a/src/actions/contacts/verify-email.js +++ b/src/actions/contacts/verify-email.js @@ -12,10 +12,8 @@ const contacts = require('../../utils/contacts'); * * @apiParam (Payload) {String} secret - */ -module.exports = async function verifyEmail({ secret }) { - const contact = await contacts.verifyEmail.call(this, { - token: secret, - }); +module.exports = async function verifyEmail({ params: { secret } }) { + const contact = await contacts.verifyEmail.call(this, { secret }); return { data: { diff --git a/src/configs/verification-challenges.js b/src/configs/verification-challenges.js index 393c918cd..fc2ac24a0 100644 --- a/src/configs/verification-challenges.js +++ b/src/configs/verification-challenges.js @@ -74,5 +74,5 @@ exports.phone = { */ exports.contacts = { max: 5, - onlyOneEmail: true, + onlyOneVerifiedEmail: true, }; diff --git a/src/migrations/09-username-to-contact-email/index.js b/src/migrations/09-username-to-contact-email/index.js new file mode 100644 index 000000000..e6be0167e --- /dev/null +++ b/src/migrations/09-username-to-contact-email/index.js @@ -0,0 +1,51 @@ +// Copy activated users username (if its email) to verified contact email +// +const Promise = require('bluebird'); +const { + // keys(parts): hash + USERS_DATA, + // keys: set + USERS_INDEX, + USERS_CONTACTS, +} = require('../../constants'); +const redisKey = require('../../utils/key'); + +const stringifyObj = (obj) => { + const newObj = Object.create(null); + for (const [key, value] of Object.entries(obj)) { + newObj[key] = JSON.stringify(value); + } + + return newObj; +}; + +function copyUsernameToContact({ + redis, log, +}) { + // used for renaming metadata keys + const pipeline = redis.pipeline(); + + return redis + .smembers(USERS_INDEX) + .tap(({ length }) => log.info('Users count:', length)) + .map((userId) => Promise.join(userId, redis.hgetall(redisKey(userId, USERS_DATA)))) + .each(([userId, { username, active }]) => { + log.info('Progress userId: ', userId, 'username: ', username); + if (/@/.test(username) && active) { + log.info('email: ', username); + const key = redisKey(userId, USERS_CONTACTS, username); + pipeline.hmset(key, stringifyObj({ + value: username, + type: 'email', + verified: true, + })); + } + }) + .then(() => pipeline.exec()); +} + +module.exports = { + script: copyUsernameToContact, + min: 1, + final: 4, +}; diff --git a/src/utils/contacts.js b/src/utils/contacts.js index 55b41ea60..0f2126be9 100644 --- a/src/utils/contacts.js +++ b/src/utils/contacts.js @@ -43,7 +43,21 @@ async function setVerifiedIfExist({ redis, userId, value }) { return key; } -async function add({ userId, contact, skipChallenge }) { +async function setAllEmailContactsOfUserAsUnVerified(redis, userId) { + const key = redisKey(userId, USERS_CONTACTS); + const contacts = await redis.smembers(key); + if (contacts.length) { + const pipe = redis.pipeline(); + contacts.forEach((value) => { + if (/@/.test(value)) { + pipe.hset(redisKey(userId, USERS_CONTACTS, value), 'verified', false); + } + }); + await pipe.exec().then(handlePipeline); + } +} + +async function add({ userId, contact, skipChallenge = false }) { this.log.debug({ userId, contact }, 'add contact key params'); const { redis } = this; @@ -53,9 +67,13 @@ async function add({ userId, contact, skipChallenge }) { try { const key = redisKey(userId, USERS_CONTACTS, contact.value); + if (skipChallenge && this.config.contacts.onlyOneVerifiedEmail) { + await setAllEmailContactsOfUserAsUnVerified(redis, userId); + } + const contactData = { ...contact, - verified: false, + verified: skipChallenge, }; const pipe = redis.pipeline(); @@ -66,10 +84,6 @@ async function add({ userId, contact, skipChallenge }) { pipe.set(redisKey(userId, USERS_DEFAULT_CONTACT), contact.value); } - if (skipChallenge) { - pipe.hset(key, 'verified', true); - } - await pipe.exec().then(handlePipeline); return contactData; @@ -118,23 +132,12 @@ async function challenge({ userId, contact, i18nLocale }) { }; } -async function setAllEmailContactsOfUserAsUnVerified(redis, userId) { - const key = redisKey(userId, USERS_CONTACTS); - const contacts = await redis.smembers(key); - - if (contacts.length) { - const pipe = redis.pipeline(); - contacts.forEach((kkey) => pipe.hset(kkey, 'verified', false)); - await pipe.exec().then(handlePipeline); - } -} - -async function verifyEmail(secret) { +async function verifyEmail({ secret }) { const { redis, tokenManager } = this; const { metadata: { contact, userId } } = await tokenManager.verify(secret); const key = redisKey(userId, USERS_CONTACTS, contact.value); - if (this.config.contacts.onlyOneEmail) { + if (this.config.contacts.onlyOneVerifiedEmail) { await setAllEmailContactsOfUserAsUnVerified(redis, userId); } diff --git a/test/suites/actions/activate.js b/test/suites/actions/activate.js index 80485b00b..a4241d1f9 100644 --- a/test/suites/actions/activate.js +++ b/test/suites/actions/activate.js @@ -120,4 +120,25 @@ describe('#activate', function activateSuite() { assert.equal(response.user.id, userId); assert.ok(/^\d+$/.test(response.user.metadata[opts.audience].aa)); }); + + it('should verify contact too if shouldVerifyContact true', async function test() { + const user = { + username: 'v1@makeomatic.ru', password: '123', audience: 'ok', metadata: { wolf: true }, activate: false, + }; + await this.users.dispatch('register', { params: user }); + + const params = { + username: user.username, + contact: { + value: user.username, + type: 'email', + }, + }; + + await this.users.dispatch('contacts.add', { params }); + await this.users.dispatch('activate', { params: { username: user.username, shouldVerifyContact: true } }); + const { data } = await this.users.dispatch('contacts.list', { params: { username: user.username } }); + const firstContact = data.find((contact) => contact.value === params.contact.value); + assert.strictEqual(firstContact.verified, true); + }); }); diff --git a/test/suites/actions/contacts.js b/test/suites/actions/contacts.js index d5cab70d4..404d4532d 100644 --- a/test/suites/actions/contacts.js +++ b/test/suites/actions/contacts.js @@ -178,4 +178,64 @@ describe('#user contacts', function registerSuite() { const list = await this.users.dispatch('contacts.list', { params: { username: this.testUser.username } }); assert.equal(list.data.length, 0); }); + + it('must be able to add user contact with skipChallenge, onlyOneVerifiedEmail', async function test() { + const params = { + username: this.testUser.username, + skipChallenge: true, + contact: { + value: 'nomail@example.com', + type: 'email', + }, + }; + + const { data: { attributes: { value, verified } } } = await this.users.dispatch('contacts.add', { params }); + assert.equal(value, params.contact.value); + assert.strictEqual(verified, true); + + const params2 = { + username: this.testUser.username, + skipChallenge: true, + contact: { + value: 'nomail2@example.com', + type: 'email', + }, + }; + + const { data: { attributes } } = await this.users.dispatch('contacts.add', { params: params2 }); + assert.equal(attributes.value, params2.contact.value); + assert.strictEqual(attributes.verified, true); + + const { data } = await this.users.dispatch('contacts.list', { params: { username: this.testUser.username } }); + const firstContact = data.find((contact) => contact.value === params.contact.value); + assert.strictEqual(firstContact.verified, false); + }); + + it('should validate email', async function test() { + const sendMailPath = 'mailer.predefined'; + const params = { + username: this.testUser.username, + contact: { + value: 'nomail@example.com', + type: 'email', + }, + }; + + await this.users.dispatch('contacts.add', { params }); + + const amqpStub = sinon.stub(this.users.amqp, 'publish'); + + amqpStub.withArgs(sendMailPath) + .resolves({ queued: true }); + + await this.users.dispatch('contacts.challenge', { params }); + const { args: [[path, { ctx: { template: { token: { secret } } } }]] } = amqpStub; + assert.equal(path, sendMailPath); + + const { data: { attributes: { value, verified } } } = await this.users.dispatch('contacts.verify-email', { params: { secret } }); + + assert.equal(value, params.contact.value); + assert.strictEqual(verified, true); + amqpStub.restore(); + }); }); From f99a648e22ce6a4d44e0cc402d4a9d9ce9d35f13 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Fri, 27 May 2022 18:52:32 +0300 Subject: [PATCH 03/16] feat: contact email --- .../09-username-to-contact-email/index.js | 67 +++++++++++++------ 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/src/migrations/09-username-to-contact-email/index.js b/src/migrations/09-username-to-contact-email/index.js index e6be0167e..dd3b27ebc 100644 --- a/src/migrations/09-username-to-contact-email/index.js +++ b/src/migrations/09-username-to-contact-email/index.js @@ -9,6 +9,7 @@ const { USERS_CONTACTS, } = require('../../constants'); const redisKey = require('../../utils/key'); +const handlePipeline = require('../../utils/pipeline-error'); const stringifyObj = (obj) => { const newObj = Object.create(null); @@ -20,32 +21,54 @@ const stringifyObj = (obj) => { }; function copyUsernameToContact({ - redis, log, + redis, + log, }) { - // used for renaming metadata keys - const pipeline = redis.pipeline(); - - return redis - .smembers(USERS_INDEX) - .tap(({ length }) => log.info('Users count:', length)) - .map((userId) => Promise.join(userId, redis.hgetall(redisKey(userId, USERS_DATA)))) - .each(([userId, { username, active }]) => { - log.info('Progress userId: ', userId, 'username: ', username); - if (/@/.test(username) && active) { - log.info('email: ', username); - const key = redisKey(userId, USERS_CONTACTS, username); - pipeline.hmset(key, stringifyObj({ - value: username, - type: 'email', - verified: true, - })); + return (new Promise((resolve, reject) => { + const stream = redis.sscanStream(USERS_INDEX, { + count: 1000, + }); + let usersCount = 0; + + stream.on('data', async (usersIds) => { + stream.pause(); + + const pipeline = redis.pipeline(); + for await (const userId of usersIds) { + usersCount += 1; + const { username, active } = await redis.hgetall(redisKey(userId, USERS_DATA)); + + log.info('Progress userId: ', userId, 'username: ', username); + if (/@/.test(username) && active) { + log.info('email: ', username); + const key = redisKey(userId, USERS_CONTACTS, username); + pipeline.hmset(key, stringifyObj({ + value: username, + type: 'email', + verified: true, + })); + } } - }) - .then(() => pipeline.exec()); + + pipeline.exec() + .then(handlePipeline) + .then(() => { + stream.resume(); + return true; + }) + .catch(reject); + }); + + stream.on('error', reject); + stream.on('end', () => { + log.info(`Process ${usersCount} users`); + resolve(); + }); + })); } module.exports = { script: copyUsernameToContact, - min: 1, - final: 4, + min: 8, + final: 9, }; From 5cf53fe9cf4469191c243d507bcd61626d346db0 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Fri, 27 May 2022 20:08:31 +0300 Subject: [PATCH 04/16] feat: contact email --- src/utils/contacts.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/utils/contacts.js b/src/utils/contacts.js index 0f2126be9..0040860d1 100644 --- a/src/utils/contacts.js +++ b/src/utils/contacts.js @@ -37,7 +37,7 @@ async function setVerifiedIfExist({ redis, userId, value }) { const key = redisKey(userId, USERS_CONTACTS, value); const exist = await redis.hexists(key, 'verified'); if (exist) { - await redis.hset(key, 'verified', true); + await redis.hset(key, 'verified', 'true'); } return key; @@ -50,7 +50,7 @@ async function setAllEmailContactsOfUserAsUnVerified(redis, userId) { const pipe = redis.pipeline(); contacts.forEach((value) => { if (/@/.test(value)) { - pipe.hset(redisKey(userId, USERS_CONTACTS, value), 'verified', false); + pipe.hset(redisKey(userId, USERS_CONTACTS, value), 'verified', 'false'); } }); await pipe.exec().then(handlePipeline); @@ -136,14 +136,15 @@ async function verifyEmail({ secret }) { const { redis, tokenManager } = this; const { metadata: { contact, userId } } = await tokenManager.verify(secret); const key = redisKey(userId, USERS_CONTACTS, contact.value); + const pipe = redis.pipeline(); if (this.config.contacts.onlyOneVerifiedEmail) { await setAllEmailContactsOfUserAsUnVerified(redis, userId); } - await redis.hset(key, 'verified', true); - - return redis.hgetall(key).then(parseObj); + pipe.hset(key, 'verified', 'true'); + pipe.hgetall(key); + return pipe.exec().then(handlePipeline).then(([, verifiedContact]) => parseObj(verifiedContact)); } async function verify({ userId, contact, token }) { @@ -162,7 +163,7 @@ async function verify({ userId, contact, token }) { }; await tokenManager.verify(args, { erase: false }); - await redis.hset(key, 'verified', true); + await redis.hset(key, 'verified', 'true'); return redis.hgetall(key).then(parseObj); } From 797f99a956c06128a1418c39570e353bad68acb4 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Fri, 27 May 2022 20:40:16 +0300 Subject: [PATCH 05/16] feat: contact email --- schemas/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemas/common.json b/schemas/common.json index b6c744c7f..b6587c1f4 100644 --- a/schemas/common.json +++ b/schemas/common.json @@ -68,7 +68,7 @@ { "format": "email" }, { "format": "uuid" }, { "pattern": "^\\d+$" }, - { "pattern": "^(fb|sso|ma)/[\\d_]+$" } + { "pattern": "^(fb|apl|sso|ma)/[\\d_]+$" } ] }, "alias": { From 563ac9c5d1f4e0f00ee308abe8503732ab8d1ed2 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Sat, 28 May 2022 12:41:17 +0300 Subject: [PATCH 06/16] feat: contact email --- src/actions/contacts/challenge.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/contacts/challenge.js b/src/actions/contacts/challenge.js index 108240aef..836e728d1 100644 --- a/src/actions/contacts/challenge.js +++ b/src/actions/contacts/challenge.js @@ -13,7 +13,7 @@ const { getUserId } = require('../../utils/userData'); * @apiParam (Payload) {String} username - */ module.exports = async function challenge({ params }) { - const { metadata: { i18nLocale } = {}, contact } = params; + const { i18nLocale, contact } = params; const userId = await getUserId.call(this, params.username); const attributes = await contacts.challenge.call(this, { contact, userId, i18nLocale }); From 4245b3551469d1b7300cf586946d2fc304a2dedd Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Tue, 31 May 2022 22:11:56 +0300 Subject: [PATCH 07/16] feat: contact email --- scripts/setVerifyContactIfExist.lua | 7 +++++++ src/utils/contacts.js | 16 +++++++--------- test/suites/actions/contacts.js | 5 +++-- 3 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 scripts/setVerifyContactIfExist.lua diff --git a/scripts/setVerifyContactIfExist.lua b/scripts/setVerifyContactIfExist.lua new file mode 100644 index 000000000..ced812221 --- /dev/null +++ b/scripts/setVerifyContactIfExist.lua @@ -0,0 +1,7 @@ +local userContactsKey = KEYS[1]; +local verifyKey = ARGV[1] +local verifyValue = ARGV[2]; + +if redis.call("EXISTS", userContactsKey) == 1 then + redis.call("HSET", userContactsKey, verifyKey, verifyValue); +end diff --git a/src/utils/contacts.js b/src/utils/contacts.js index 0040860d1..57ed31804 100644 --- a/src/utils/contacts.js +++ b/src/utils/contacts.js @@ -35,22 +35,20 @@ async function checkLimit({ userId }) { async function setVerifiedIfExist({ redis, userId, value }) { const key = redisKey(userId, USERS_CONTACTS, value); - const exist = await redis.hexists(key, 'verified'); - if (exist) { - await redis.hset(key, 'verified', 'true'); - } + await redis.setVerifyContactIfExist(1, key, 'verified', 'true'); return key; } -async function setAllEmailContactsOfUserAsUnVerified(redis, userId) { +async function removeAllEmailContactsOfUser(redis, userId, exceptEmail) { const key = redisKey(userId, USERS_CONTACTS); const contacts = await redis.smembers(key); if (contacts.length) { const pipe = redis.pipeline(); contacts.forEach((value) => { - if (/@/.test(value)) { - pipe.hset(redisKey(userId, USERS_CONTACTS, value), 'verified', 'false'); + if (/@/.test(value) && value !== exceptEmail) { + pipe.del(redisKey(userId, USERS_CONTACTS, value)); + pipe.srem(redisKey(userId, USERS_CONTACTS), value); } }); await pipe.exec().then(handlePipeline); @@ -68,7 +66,7 @@ async function add({ userId, contact, skipChallenge = false }) { const key = redisKey(userId, USERS_CONTACTS, contact.value); if (skipChallenge && this.config.contacts.onlyOneVerifiedEmail) { - await setAllEmailContactsOfUserAsUnVerified(redis, userId); + await removeAllEmailContactsOfUser(redis, userId); } const contactData = { @@ -139,7 +137,7 @@ async function verifyEmail({ secret }) { const pipe = redis.pipeline(); if (this.config.contacts.onlyOneVerifiedEmail) { - await setAllEmailContactsOfUserAsUnVerified(redis, userId); + await removeAllEmailContactsOfUser(redis, userId, contact.value); } pipe.hset(key, 'verified', 'true'); diff --git a/test/suites/actions/contacts.js b/test/suites/actions/contacts.js index 404d4532d..fce4022c9 100644 --- a/test/suites/actions/contacts.js +++ b/test/suites/actions/contacts.js @@ -207,8 +207,9 @@ describe('#user contacts', function registerSuite() { assert.strictEqual(attributes.verified, true); const { data } = await this.users.dispatch('contacts.list', { params: { username: this.testUser.username } }); - const firstContact = data.find((contact) => contact.value === params.contact.value); - assert.strictEqual(firstContact.verified, false); + assert.equal(data.length, 1); + assert.strictEqual(data[0].verified, true); + assert.equal(data[0].value, params2.contact.value); }); it('should validate email', async function test() { From 440847ebde9560a382318c5df5e6a3e736bd506f Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Fri, 3 Jun 2022 13:53:47 +0300 Subject: [PATCH 08/16] fix: remove unchalleged concat --- src/utils/contacts.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/utils/contacts.js b/src/utils/contacts.js index 57ed31804..016c49306 100644 --- a/src/utils/contacts.js +++ b/src/utils/contacts.js @@ -167,7 +167,7 @@ async function verify({ userId, contact, token }) { } async function remove({ userId, contact }) { - const { redis, tokenManager } = this; + const { redis, tokenManager, log } = this; const key = redisKey(userId, USERS_CONTACTS, contact.value); const contactData = await redis.hgetall(key).then(parseObj); @@ -183,10 +183,14 @@ async function remove({ userId, contact }) { throw new HttpStatusError(400, 'cannot remove default contact'); } - await tokenManager.remove({ - id: contact.value, - action: USERS_ACTION_VERIFY_CONTACT, - }); + try { + await tokenManager.remove({ + id: contact.value, + action: USERS_ACTION_VERIFY_CONTACT, + }); + } catch (e) { + log.debug(e, 'Challenge havent been invoked on this removing contact'); + } const pipe = redis.pipeline(); pipe.del(key); From f568909c08444ef04fb7e2139e2f4a86c3ab8930 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Fri, 3 Jun 2022 17:16:46 +0300 Subject: [PATCH 09/16] fix: remove unchalleged concat --- test/suites/actions/contacts.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/suites/actions/contacts.js b/test/suites/actions/contacts.js index fce4022c9..5f02ca108 100644 --- a/test/suites/actions/contacts.js +++ b/test/suites/actions/contacts.js @@ -179,6 +179,26 @@ describe('#user contacts', function registerSuite() { assert.equal(list.data.length, 0); }); + it('must be able to remove unchallenged contact', async function test() { + const params = { + username: this.testUser.username, + contact: { + value: 'unchallenged@example.com', + type: 'email', + }, + }; + + const { data: { attributes: { value, verified } } } = await this.users.dispatch('contacts.add', { params: { ...params, skipChallenge: true } }); + assert.equal(value, params.contact.value); + assert.strictEqual(verified, true); + + const response = await this.users.dispatch('contacts.remove', { params }); + + assert(response); + const list = await this.users.dispatch('contacts.list', { params: { username: this.testUser.username } }); + assert.equal(list.data.length, 0); + }); + it('must be able to add user contact with skipChallenge, onlyOneVerifiedEmail', async function test() { const params = { username: this.testUser.username, From 1ce6a7f27b372eb35db2ab207d700ecf8e97e8c7 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Sat, 11 Jun 2022 07:16:22 +0300 Subject: [PATCH 10/16] fix: remove allow remove first contact --- src/configs/verification-challenges.js | 1 + src/utils/contacts.js | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/configs/verification-challenges.js b/src/configs/verification-challenges.js index fc2ac24a0..c56de0503 100644 --- a/src/configs/verification-challenges.js +++ b/src/configs/verification-challenges.js @@ -75,4 +75,5 @@ exports.phone = { exports.contacts = { max: 5, onlyOneVerifiedEmail: true, + allowRemoveFirstContact: true, }; diff --git a/src/utils/contacts.js b/src/utils/contacts.js index 016c49306..155dd4b66 100644 --- a/src/utils/contacts.js +++ b/src/utils/contacts.js @@ -176,10 +176,10 @@ async function remove({ userId, contact }) { throw new HttpStatusError(404); } - const contactsCount = await checkLimit.call(this, { userId }); + const contactsCount = await this.redis.scard(redisKey(userId, USERS_CONTACTS)); const defaultContact = await redis.get(redisKey(userId, USERS_DEFAULT_CONTACT)); - if (defaultContact === contact.value && contactsCount !== 1) { + if (defaultContact === contact.value && contactsCount !== 1 && !this.config.contacts.allowRemoveFirstContact) { throw new HttpStatusError(400, 'cannot remove default contact'); } From b01c434f37d91d76baf1e3f4c6688eb96af12356 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Sat, 11 Jun 2022 08:12:08 +0300 Subject: [PATCH 11/16] fix: pass metadata through contact challenge --- src/actions/contacts/challenge.js | 4 ++-- src/actions/contacts/verify-email.js | 4 ++-- src/utils/contacts.js | 15 ++++++++------- test/suites/actions/contacts.js | 7 ++++++- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/actions/contacts/challenge.js b/src/actions/contacts/challenge.js index 836e728d1..5ee71e1db 100644 --- a/src/actions/contacts/challenge.js +++ b/src/actions/contacts/challenge.js @@ -13,9 +13,9 @@ const { getUserId } = require('../../utils/userData'); * @apiParam (Payload) {String} username - */ module.exports = async function challenge({ params }) { - const { i18nLocale, contact } = params; + const { i18nLocale, contact, metadata } = params; const userId = await getUserId.call(this, params.username); - const attributes = await contacts.challenge.call(this, { contact, userId, i18nLocale }); + const attributes = await contacts.challenge.call(this, { contact, userId, i18nLocale, metadata }); return { data: { diff --git a/src/actions/contacts/verify-email.js b/src/actions/contacts/verify-email.js index beb79e249..e64ff33f9 100644 --- a/src/actions/contacts/verify-email.js +++ b/src/actions/contacts/verify-email.js @@ -13,11 +13,11 @@ const contacts = require('../../utils/contacts'); * @apiParam (Payload) {String} secret - */ module.exports = async function verifyEmail({ params: { secret } }) { - const contact = await contacts.verifyEmail.call(this, { secret }); + const attributes = await contacts.verifyEmail.call(this, { secret }); return { data: { - attributes: contact, + attributes, }, }; }; diff --git a/src/utils/contacts.js b/src/utils/contacts.js index 155dd4b66..53fb658fe 100644 --- a/src/utils/contacts.js +++ b/src/utils/contacts.js @@ -106,7 +106,7 @@ async function list({ userId }) { return []; } -async function challenge({ userId, contact, i18nLocale }) { +async function challenge({ userId, contact, i18nLocale, metadata = {} }) { const { redis } = this; const key = redisKey(userId, USERS_CONTACTS, contact.value); @@ -118,7 +118,7 @@ async function challenge({ userId, contact, i18nLocale }) { throttle, action: USERS_ACTION_VERIFY_CONTACT, id: contact.value, - metadata: { contact, userId }, + metadata: { metadata, contact, userId }, ...this.config.token[contactData.type], }; @@ -132,17 +132,18 @@ async function challenge({ userId, contact, i18nLocale }) { async function verifyEmail({ secret }) { const { redis, tokenManager } = this; - const { metadata: { contact, userId } } = await tokenManager.verify(secret); + const { metadata } = await tokenManager.verify(secret); + const { userId, contact } = metadata; const key = redisKey(userId, USERS_CONTACTS, contact.value); - const pipe = redis.pipeline(); if (this.config.contacts.onlyOneVerifiedEmail) { await removeAllEmailContactsOfUser(redis, userId, contact.value); } - pipe.hset(key, 'verified', 'true'); - pipe.hgetall(key); - return pipe.exec().then(handlePipeline).then(([, verifiedContact]) => parseObj(verifiedContact)); + await redis.hset(key, 'verified', 'true'); + metadata.contact.verified = true; + + return metadata; } async function verify({ userId, contact, token }) { diff --git a/test/suites/actions/contacts.js b/test/suites/actions/contacts.js index 5f02ca108..476f88c16 100644 --- a/test/suites/actions/contacts.js +++ b/test/suites/actions/contacts.js @@ -240,6 +240,9 @@ describe('#user contacts', function registerSuite() { value: 'nomail@example.com', type: 'email', }, + metadata: { + name: 'Test', + }, }; await this.users.dispatch('contacts.add', { params }); @@ -253,10 +256,12 @@ describe('#user contacts', function registerSuite() { const { args: [[path, { ctx: { template: { token: { secret } } } }]] } = amqpStub; assert.equal(path, sendMailPath); - const { data: { attributes: { value, verified } } } = await this.users.dispatch('contacts.verify-email', { params: { secret } }); + const { data: { attributes: { contact: { value, verified }, metadata: { name } } } } = await this.users + .dispatch('contacts.verify-email', { params: { secret } }); assert.equal(value, params.contact.value); assert.strictEqual(verified, true); + assert.equal(name, params.metadata.name); amqpStub.restore(); }); }); From ec32fd11cea4082546330b1bed47ee3a654d6d83 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Wed, 22 Jun 2022 19:42:15 +0300 Subject: [PATCH 12/16] fix: verify email on activate fix --- src/actions/activate.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/actions/activate.js b/src/actions/activate.js index de65fb59c..10a23ca08 100644 --- a/src/actions/activate.js +++ b/src/actions/activate.js @@ -120,9 +120,10 @@ function verifyRequest() { async function activateAccount(data, metadata) { const userId = data[USERS_ID_FIELD]; const alias = data[USERS_ALIAS_FIELD]; + const username = data[USERS_USERNAME_FIELD]; const referral = metadata[USERS_REFERRAL_FIELD]; const userKey = redisKey(userId, USERS_DATA); - const { defaultAudience, service } = this; + const { defaultAudience, service, shouldVerifyContact } = this; const { redis } = service; // if this goes through, but other async calls fail its ok to repeat that @@ -160,6 +161,10 @@ async function activateAccount(data, metadata) { throw new HttpStatusError(417, `Account ${userId} was already activated`); } + if (shouldVerifyContact) { + await contacts.setVerifiedIfExist({ redis: this.redis, userId, value: username }); + } + return userId; } @@ -208,6 +213,7 @@ async function activateAction({ log, params }) { defaultAudience, token, username, + shouldVerifyContact, service: this, erase: config.token.erase, }; @@ -223,10 +229,6 @@ async function activateAction({ log, params }) { .spread(activateAccount) .tap(hook); - if (shouldVerifyContact) { - await contacts.setVerifiedIfExist({ redis: this.redis, userId, value: username || userId }); - } - return jwt.login.call(this, userId, audience, isStatelessAuth); } From 1984d032518ef33203e2f6f7dffe05385145d305 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Wed, 22 Jun 2022 20:52:47 +0300 Subject: [PATCH 13/16] fix: verify email on activate fix --- src/actions/activate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/activate.js b/src/actions/activate.js index 10a23ca08..940e490ea 100644 --- a/src/actions/activate.js +++ b/src/actions/activate.js @@ -162,7 +162,7 @@ async function activateAccount(data, metadata) { } if (shouldVerifyContact) { - await contacts.setVerifiedIfExist({ redis: this.redis, userId, value: username }); + await contacts.setVerifiedIfExist({ redis, userId, value: username }); } return userId; From ea45af725ce344d539658877231cdce259c35874 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Thu, 23 Jun 2022 07:40:52 +0300 Subject: [PATCH 14/16] fix: verify email on activate fix --- src/actions/activate.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/actions/activate.js b/src/actions/activate.js index 940e490ea..fe68f7063 100644 --- a/src/actions/activate.js +++ b/src/actions/activate.js @@ -104,11 +104,7 @@ function verifyRequest() { return verifyToken.call(context, token, { erase, control: { action } }); } - if (username) { - return Promise.resolve(username); - } - - throw new HttpStatusError(400, 'invalid params'); + return Promise.resolve(username); } /** From 3e7eb659d7b47a8701159f54bccb273485d79d04 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Thu, 23 Jun 2022 09:16:30 +0300 Subject: [PATCH 15/16] fix: verify email on activate fix --- src/actions/activate.js | 6 +++++- test/suites/actions/activate.js | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/actions/activate.js b/src/actions/activate.js index fe68f7063..940e490ea 100644 --- a/src/actions/activate.js +++ b/src/actions/activate.js @@ -104,7 +104,11 @@ function verifyRequest() { return verifyToken.call(context, token, { erase, control: { action } }); } - return Promise.resolve(username); + if (username) { + return Promise.resolve(username); + } + + throw new HttpStatusError(400, 'invalid params'); } /** diff --git a/test/suites/actions/activate.js b/test/suites/actions/activate.js index a4241d1f9..a7fbc0f8d 100644 --- a/test/suites/actions/activate.js +++ b/test/suites/actions/activate.js @@ -52,6 +52,14 @@ describe('#activate', function activateSuite() { return true; }); }); + + it('must fail to activate already active user', async function test() { + await assert.rejects(this.users.dispatch('activate', { params: { token: this.token, username: email } }), (activation) => { + expect(activation.name).to.be.eq('HttpStatusError'); + expect(activation.statusCode).to.be.eq(409); + return true; + }); + }); }); describe('activate inactive user', function suite() { From 2dd6786f3f8cf42998670a9386e7fa8baa780b58 Mon Sep 17 00:00:00 2001 From: Oleg Tokar Date: Thu, 23 Jun 2022 09:53:58 +0300 Subject: [PATCH 16/16] fix: fix coverage --- test/suites/actions/activate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/suites/actions/activate.js b/test/suites/actions/activate.js index a7fbc0f8d..f91fa9fb0 100644 --- a/test/suites/actions/activate.js +++ b/test/suites/actions/activate.js @@ -53,7 +53,7 @@ describe('#activate', function activateSuite() { }); }); - it('must fail to activate already active user', async function test() { + it('must fail to activate already active user with both token and username provided', async function test() { await assert.rejects(this.users.dispatch('activate', { params: { token: this.token, username: email } }), (activation) => { expect(activation.name).to.be.eq('HttpStatusError'); expect(activation.statusCode).to.be.eq(409);