From 7f14cada43ffc8907f488b5e3ba851f2f53e8b43 Mon Sep 17 00:00:00 2001 From: dblythy Date: Thu, 19 Nov 2020 02:10:29 +1100 Subject: [PATCH 1/6] Reuse tokens if they haven't expired --- spec/EmailVerificationToken.spec.js | 85 ++++++++++++++++++++++- spec/PasswordPolicy.spec.js | 97 ++++++++++++++++++++++++++ src/Controllers/UserController.js | 104 ++++++++++++++++------------ src/Options/Definitions.js | 6 ++ src/Options/docs.js | 1 + src/Options/index.js | 3 + 6 files changed, 248 insertions(+), 48 deletions(-) diff --git a/spec/EmailVerificationToken.spec.js b/spec/EmailVerificationToken.spec.js index b109f7f244..dc5279e026 100644 --- a/spec/EmailVerificationToken.spec.js +++ b/spec/EmailVerificationToken.spec.js @@ -510,7 +510,7 @@ describe('Email Verification Token Expiration: ', () => { userAfterEmailReset._email_verify_token ); expect(userBeforeEmailReset._email_verify_token_expires_at).not.toEqual( - userAfterEmailReset.__email_verify_token_expires_at + userAfterEmailReset._email_verify_token_expires_at ); expect(sendEmailOptions).toBeDefined(); done(); @@ -594,7 +594,88 @@ describe('Email Verification Token Expiration: ', () => { userAfterRequest._email_verify_token ); expect(userBeforeRequest._email_verify_token_expires_at).not.toEqual( - userAfterRequest.__email_verify_token_expires_at + userAfterRequest._email_verify_token_expires_at + ); + done(); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + + it('should match codes with emailVerifyTokenReuseIfValid', done => { + const user = new Parse.User(); + let sendEmailOptions; + let sendVerificationEmailCallCount = 0; + let userBeforeRequest; + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendVerificationEmailCallCount++; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5 * 60, // 5 minutes + publicServerURL: 'http://localhost:8378/1', + emailVerifyTokenReuseIfValid: true, + }) + .then(() => { + user.setUsername('resends_verification_token'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + return user.signUp(); + }) + .then(() => { + const config = Config.get('test'); + return config.database + .find('_User', { username: 'resends_verification_token' }) + .then(results => { + return results[0]; + }); + }) + .then(newUser => { + // store this user before we make our email request + userBeforeRequest = newUser; + expect(sendVerificationEmailCallCount).toBe(1); + + return request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { + email: 'user@parse.com', + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + }) + .then(response => { + expect(response.status).toBe(200); + expect(sendVerificationEmailCallCount).toBe(2); + expect(sendEmailOptions).toBeDefined(); + + // query for this user again + const config = Config.get('test'); + return config.database + .find('_User', { username: 'resends_verification_token' }) + .then(results => { + return results[0]; + }); + }) + .then(userAfterRequest => { + // verify that our token & expiration has been changed for this new request + expect(typeof userAfterRequest).toBe('object'); + expect(userBeforeRequest._email_verify_token).toEqual(userAfterRequest._email_verify_token); + expect(userBeforeRequest._email_verify_token_expires_at).toEqual( + userAfterRequest._email_verify_token_expires_at ); done(); }) diff --git a/spec/PasswordPolicy.spec.js b/spec/PasswordPolicy.spec.js index 790992262b..3082b46621 100644 --- a/spec/PasswordPolicy.spec.js +++ b/spec/PasswordPolicy.spec.js @@ -122,6 +122,103 @@ describe('Password Policy: ', () => { }); }); + it('should not keep reset token', done => { + const user = new Parse.User(); + const sendEmailOptions = []; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions.push(options); + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenValidityDuration: 5 * 60, // 5 minutes + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + user.setUsername('testResetTokenValidity'); + user.setPassword('original'); + user.set('email', 'user@parse.com'); + return user.signUp(); + }) + .then(() => { + return Parse.User.requestPasswordReset('user@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .then(() => { + return Parse.User.requestPasswordReset('user@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .then(() => { + expect(sendEmailOptions[0].link).not.toBe(sendEmailOptions[1].link); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('should keep reset token with resetTokenReuseIfValid', done => { + const user = new Parse.User(); + const sendEmailOptions = []; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions.push(options); + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenValidityDuration: 5 * 60, // 5 minutes + resetTokenReuseIfValid: true, + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + user.setUsername('testResetTokenValidity'); + user.setPassword('original'); + user.set('email', 'user@parse.com'); + return user.signUp(); + }) + .then(() => { + return Parse.User.requestPasswordReset('user@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .then(() => { + return Parse.User.requestPasswordReset('user@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .then(() => { + expect(sendEmailOptions[0].link).toBe(sendEmailOptions[1].link); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + it('should fail if passwordPolicy.resetTokenValidityDuration is not a number', done => { reconfigureServer({ appName: 'passwordPolicy', diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index d853f7feaa..326e90a82b 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -81,30 +81,27 @@ export class UserController extends AdaptableController { } checkResetTokenValidity(username, token) { - return this.config.database - .find( - '_User', - { - username: username, - _perishable_token: token, - }, - { limit: 1 } - ) - .then(results => { - if (results.length != 1) { - throw 'Failed to reset password: username / email / token is invalid'; - } + let query = { + username: username, + _perishable_token: token, + }; + if (!token) { + query = { $or: [{ email: username }, { username, email: { $exists: false } }] }; + } + return this.config.database.find('_User', query, { limit: 1 }).then(results => { + if (results.length != 1) { + throw 'Failed to reset password: username / email / token is invalid'; + } - if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) { - let expiresDate = results[0]._perishable_token_expires_at; - if (expiresDate && expiresDate.__type == 'Date') { - expiresDate = new Date(expiresDate.iso); - } - if (expiresDate < new Date()) throw 'The password reset link has expired'; + if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) { + let expiresDate = results[0]._perishable_token_expires_at; + if (expiresDate && expiresDate.__type == 'Date') { + expiresDate = new Date(expiresDate.iso); } - - return results[0]; - }); + if (expiresDate < new Date()) throw 'The password reset link has expired'; + } + return results[0]; + }); } getUserIfNeeded(user) { @@ -158,6 +155,15 @@ export class UserController extends AdaptableController { * @returns {*} */ regenerateEmailVerifyToken(user) { + const { _email_verify_token, _email_verify_token_expires_at } = user; + if ( + this.config.emailVerifyTokenReuseIfValid && + this.config.emailVerifyTokenValidityDuration && + _email_verify_token && + new Date() < new Date(_email_verify_token_expires_at) + ) { + return Promise.resolve(); + } this.setEmailVerifyToken(user); return this.config.database.update('_User', { username: user.username }, user); } @@ -191,36 +197,42 @@ export class UserController extends AdaptableController { ); } - sendPasswordResetEmail(email) { + async sendPasswordResetEmail(email) { if (!this.adapter) { throw 'Trying to send a reset password but no adapter is set'; // TODO: No adapter? } - - return this.setPasswordResetToken(email).then(user => { - const token = encodeURIComponent(user._perishable_token); - const username = encodeURIComponent(user.username); - - const link = buildEmailLink( - this.config.requestResetPasswordURL, - username, - token, - this.config - ); - const options = { - appName: this.config.appName, - link: link, - user: inflate('_User', user), - }; - - if (this.adapter.sendPasswordResetEmail) { - this.adapter.sendPasswordResetEmail(options); - } else { - this.adapter.sendMail(this.defaultResetPasswordEmail(options)); + let user; + if ( + this.config.passwordPolicy.resetTokenReuseIfValid && + this.config.resetTokenValidityDuration + ) { + try { + user = await this.checkResetTokenValidity(email); + } catch (e) { + /* */ } + } + if (!user || !user._perishable_token) { + user = await this.setPasswordResetToken(email); + } + const token = encodeURIComponent(user._perishable_token); + const username = encodeURIComponent(user.username); + + const link = buildEmailLink(this.config.requestResetPasswordURL, username, token, this.config); + const options = { + appName: this.config.appName, + link: link, + user: inflate('_User', user), + }; - return Promise.resolve(user); - }); + if (this.adapter.sendPasswordResetEmail) { + this.adapter.sendPasswordResetEmail(options); + } else { + this.adapter.sendMail(this.defaultResetPasswordEmail(options)); + } + + return Promise.resolve(user); } updatePassword(username, token, password) { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index c3c1271786..33b6b66bd8 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -125,6 +125,12 @@ module.exports.ParseServerOptions = { help: 'Adapter module for email sending', action: parsers.moduleOrObjectParser, }, + emailVerifyTokenReuseIfValid: { + env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_REUSE_IF_VALID', + help: 'an existing password reset token should be reused when a password reset is requested', + action: parsers.booleanParser, + default: false, + }, emailVerifyTokenValidityDuration: { env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_VALIDITY_DURATION', help: 'Email verification token validity duration, in seconds', diff --git a/src/Options/docs.js b/src/Options/docs.js index 9d8553d9f6..426a01daaf 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -23,6 +23,7 @@ * @property {Boolean} directAccess Replace HTTP Interface when using JS SDK in current node runtime, defaults to false. Caution, this is an experimental feature that may not be appropriate for production. * @property {String} dotNetKey Key for Unity and .Net SDK * @property {Adapter} emailAdapter Adapter module for email sending + * @property {Boolean} emailVerifyTokenReuseIfValid an existing password reset token should be reused when a password reset is requested * @property {Number} emailVerifyTokenValidityDuration Email verification token validity duration, in seconds * @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true * @property {Boolean} enableExpressErrorHandler Enables the default express error handler for all errors diff --git a/src/Options/index.js b/src/Options/index.js index 84ec9c7b99..70a14b1284 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -124,6 +124,9 @@ export interface ParseServerOptions { preventLoginWithUnverifiedEmail: ?boolean; /* Email verification token validity duration, in seconds */ emailVerifyTokenValidityDuration: ?number; + /* an existing password reset token should be reused when resend verification is requested + :DEFAULT: false */ + emailVerifyTokenReuseIfValid: ?boolean; /* account lockout policy for failed login attempts */ accountLockout: ?any; /* Password policy for enforcing password related rules */ From 9aa489aae372fce21712ac7ed045cdc444fd3b08 Mon Sep 17 00:00:00 2001 From: dblythy Date: Thu, 19 Nov 2020 08:46:17 +1100 Subject: [PATCH 2/6] Fix failing tests --- src/Controllers/UserController.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 326e90a82b..c6787d77f9 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -204,8 +204,9 @@ export class UserController extends AdaptableController { } let user; if ( + this.config.passwordPolicy && this.config.passwordPolicy.resetTokenReuseIfValid && - this.config.resetTokenValidityDuration + this.config.passwordPolicy.resetTokenValidityDuration ) { try { user = await this.checkResetTokenValidity(email); From 4a6eb57571557ed43fc80390baba618af305a2cc Mon Sep 17 00:00:00 2001 From: dblythy Date: Thu, 19 Nov 2020 10:32:32 +1100 Subject: [PATCH 3/6] Update UserController.js --- src/Controllers/UserController.js | 63 ++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index c6787d77f9..80dda2164b 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -81,27 +81,30 @@ export class UserController extends AdaptableController { } checkResetTokenValidity(username, token) { - let query = { - username: username, - _perishable_token: token, - }; - if (!token) { - query = { $or: [{ email: username }, { username, email: { $exists: false } }] }; - } - return this.config.database.find('_User', query, { limit: 1 }).then(results => { - if (results.length != 1) { - throw 'Failed to reset password: username / email / token is invalid'; - } + return this.config.database + .find( + '_User', + { + username: username, + _perishable_token: token, + }, + { limit: 1 } + ) + .then(results => { + if (results.length != 1) { + throw 'Failed to reset password: username / email / token is invalid'; + } - if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) { - let expiresDate = results[0]._perishable_token_expires_at; - if (expiresDate && expiresDate.__type == 'Date') { - expiresDate = new Date(expiresDate.iso); + if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) { + let expiresDate = results[0]._perishable_token_expires_at; + if (expiresDate && expiresDate.__type == 'Date') { + expiresDate = new Date(expiresDate.iso); + } + if (expiresDate < new Date()) throw 'The password reset link has expired'; } - if (expiresDate < new Date()) throw 'The password reset link has expired'; - } - return results[0]; - }); + + return results[0]; + }); } getUserIfNeeded(user) { @@ -208,10 +211,24 @@ export class UserController extends AdaptableController { this.config.passwordPolicy.resetTokenReuseIfValid && this.config.passwordPolicy.resetTokenValidityDuration ) { - try { - user = await this.checkResetTokenValidity(email); - } catch (e) { - /* */ + const results = await this.config.database.find( + '_User', + { + $or: [ + { email, _perishable_token: { $exists: true } }, + { username: email, email: { $exists: false }, _perishable_token: { $exists: true } }, + ], + }, + { limit: 1 } + ); + if (results.length == 1) { + let expiresDate = results[0]._perishable_token_expires_at; + if (expiresDate && expiresDate.__type == 'Date') { + expiresDate = new Date(expiresDate.iso); + } + if (expiresDate > new Date()) { + user = results[0]; + } } } if (!user || !user._perishable_token) { From bfb96e2460f3f952b5a07f915d1de06a2430adfd Mon Sep 17 00:00:00 2001 From: dblythy Date: Thu, 19 Nov 2020 12:55:31 +1100 Subject: [PATCH 4/6] Update tests --- spec/EmailVerificationToken.spec.js | 110 ++++++++++++---------------- spec/PasswordPolicy.spec.js | 88 ++++++---------------- src/Config.js | 19 +++++ src/Controllers/UserController.js | 6 +- src/Routers/UsersRouter.js | 1 + 5 files changed, 96 insertions(+), 128 deletions(-) diff --git a/spec/EmailVerificationToken.spec.js b/spec/EmailVerificationToken.spec.js index dc5279e026..a3846815e0 100644 --- a/spec/EmailVerificationToken.spec.js +++ b/spec/EmailVerificationToken.spec.js @@ -604,11 +604,9 @@ describe('Email Verification Token Expiration: ', () => { }); }); - it('should match codes with emailVerifyTokenReuseIfValid', done => { - const user = new Parse.User(); + it('should match codes with emailVerifyTokenReuseIfValid', async done => { let sendEmailOptions; let sendVerificationEmailCallCount = 0; - let userBeforeRequest; const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; @@ -617,72 +615,58 @@ describe('Email Verification Token Expiration: ', () => { sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {}, }; - reconfigureServer({ + await reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5 * 60, // 5 minutes publicServerURL: 'http://localhost:8378/1', emailVerifyTokenReuseIfValid: true, - }) - .then(() => { - user.setUsername('resends_verification_token'); - user.setPassword('expiringToken'); - user.set('email', 'user@parse.com'); - return user.signUp(); - }) - .then(() => { - const config = Config.get('test'); - return config.database - .find('_User', { username: 'resends_verification_token' }) - .then(results => { - return results[0]; - }); - }) - .then(newUser => { - // store this user before we make our email request - userBeforeRequest = newUser; - expect(sendVerificationEmailCallCount).toBe(1); - - return request({ - url: 'http://localhost:8378/1/verificationEmailRequest', - method: 'POST', - body: { - email: 'user@parse.com', - }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', - }, - }); - }) - .then(response => { - expect(response.status).toBe(200); - expect(sendVerificationEmailCallCount).toBe(2); - expect(sendEmailOptions).toBeDefined(); - - // query for this user again - const config = Config.get('test'); - return config.database - .find('_User', { username: 'resends_verification_token' }) - .then(results => { - return results[0]; - }); - }) - .then(userAfterRequest => { - // verify that our token & expiration has been changed for this new request - expect(typeof userAfterRequest).toBe('object'); - expect(userBeforeRequest._email_verify_token).toEqual(userAfterRequest._email_verify_token); - expect(userBeforeRequest._email_verify_token_expires_at).toEqual( - userAfterRequest._email_verify_token_expires_at - ); - done(); - }) - .catch(error => { - jfail(error); - done(); - }); + }); + const user = new Parse.User(); + user.setUsername('resends_verification_token'); + user.setPassword('expiringToken'); + user.set('email', 'user@example.com'); + await user.signUp(); + + const config = Config.get('test'); + const [userBeforeRequest] = await config.database.find('_User', { + username: 'resends_verification_token', + }); + // store this user before we make our email request + expect(sendVerificationEmailCallCount).toBe(1); + await new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { + email: 'user@example.com', + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(sendVerificationEmailCallCount).toBe(2); + expect(sendEmailOptions).toBeDefined(); + + const [userAfterRequest] = await config.database.find('_User', { + username: 'resends_verification_token', + }); + + // verify that our token & expiration has been changed for this new request + expect(typeof userAfterRequest).toBe('object'); + expect(userBeforeRequest._email_verify_token).toEqual(userAfterRequest._email_verify_token); + expect(userBeforeRequest._email_verify_token_expires_at).toEqual( + userAfterRequest._email_verify_token_expires_at + ); + done(); }); it('should not send a new verification email when a resend is requested and the user is VERIFIED', done => { diff --git a/spec/PasswordPolicy.spec.js b/spec/PasswordPolicy.spec.js index 3082b46621..556619aa9a 100644 --- a/spec/PasswordPolicy.spec.js +++ b/spec/PasswordPolicy.spec.js @@ -122,8 +122,7 @@ describe('Password Policy: ', () => { }); }); - it('should not keep reset token', done => { - const user = new Parse.User(); + it('should not keep reset token by default', async done => { const sendEmailOptions = []; const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), @@ -132,46 +131,26 @@ describe('Password Policy: ', () => { }, sendMail: () => {}, }; - reconfigureServer({ + await reconfigureServer({ appName: 'passwordPolicy', emailAdapter: emailAdapter, passwordPolicy: { resetTokenValidityDuration: 5 * 60, // 5 minutes }, publicServerURL: 'http://localhost:8378/1', - }) - .then(() => { - user.setUsername('testResetTokenValidity'); - user.setPassword('original'); - user.set('email', 'user@parse.com'); - return user.signUp(); - }) - .then(() => { - return Parse.User.requestPasswordReset('user@parse.com').catch(err => { - jfail(err); - fail('Reset password request should not fail'); - done(); - }); - }) - .then(() => { - return Parse.User.requestPasswordReset('user@parse.com').catch(err => { - jfail(err); - fail('Reset password request should not fail'); - done(); - }); - }) - .then(() => { - expect(sendEmailOptions[0].link).not.toBe(sendEmailOptions[1].link); - done(); - }) - .catch(err => { - jfail(err); - done(); - }); + }); + const user = new Parse.User(); + user.setUsername('testResetTokenValidity'); + user.setPassword('original'); + user.set('email', 'user@example.com'); + await user.signUp(); + await Parse.User.requestPasswordReset('user@example.com'); + await Parse.User.requestPasswordReset('user@example.com'); + expect(sendEmailOptions[0].link).not.toBe(sendEmailOptions[1].link); + done(); }); - it('should keep reset token with resetTokenReuseIfValid', done => { - const user = new Parse.User(); + it('should keep reset token with resetTokenReuseIfValid', async done => { const sendEmailOptions = []; const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), @@ -180,7 +159,7 @@ describe('Password Policy: ', () => { }, sendMail: () => {}, }; - reconfigureServer({ + await reconfigureServer({ appName: 'passwordPolicy', emailAdapter: emailAdapter, passwordPolicy: { @@ -188,35 +167,16 @@ describe('Password Policy: ', () => { resetTokenReuseIfValid: true, }, publicServerURL: 'http://localhost:8378/1', - }) - .then(() => { - user.setUsername('testResetTokenValidity'); - user.setPassword('original'); - user.set('email', 'user@parse.com'); - return user.signUp(); - }) - .then(() => { - return Parse.User.requestPasswordReset('user@parse.com').catch(err => { - jfail(err); - fail('Reset password request should not fail'); - done(); - }); - }) - .then(() => { - return Parse.User.requestPasswordReset('user@parse.com').catch(err => { - jfail(err); - fail('Reset password request should not fail'); - done(); - }); - }) - .then(() => { - expect(sendEmailOptions[0].link).toBe(sendEmailOptions[1].link); - done(); - }) - .catch(err => { - jfail(err); - done(); - }); + }); + const user = new Parse.User(); + user.setUsername('testResetTokenValidity'); + user.setPassword('original'); + user.set('email', 'user@example.com'); + await user.signUp(); + await Parse.User.requestPasswordReset('user@example.com'); + await Parse.User.requestPasswordReset('user@example.com'); + expect(sendEmailOptions[0].link).toBe(sendEmailOptions[1].link); + done(); }); it('should fail if passwordPolicy.resetTokenValidityDuration is not a number', done => { diff --git a/src/Config.js b/src/Config.js index 87081af43e..5c64df180a 100644 --- a/src/Config.js +++ b/src/Config.js @@ -70,6 +70,7 @@ export class Config { readOnlyMasterKey, allowHeaders, idempotencyOptions, + emailVerifyTokenReuseIfValid, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -82,6 +83,7 @@ export class Config { appName, publicServerURL, emailVerifyTokenValidityDuration, + emailVerifyTokenReuseIfValid, }); } @@ -190,6 +192,16 @@ export class Config { ) { throw 'passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20'; } + + if ( + passwordPolicy.resetTokenReuseIfValid && + typeof passwordPolicy.resetTokenReuseIfValid !== 'boolean' + ) { + throw 'resetTokenReuseIfValid must be a boolean value'; + } + if (passwordPolicy.resetTokenReuseIfValid && !passwordPolicy.resetTokenValidityDuration) { + throw 'You cannot use resetTokenReuseIfValid without resetTokenValidityDuration'; + } } } @@ -207,6 +219,7 @@ export class Config { appName, publicServerURL, emailVerifyTokenValidityDuration, + emailVerifyTokenReuseIfValid, }) { if (!emailAdapter) { throw 'An emailAdapter is required for e-mail verification and password resets.'; @@ -224,6 +237,12 @@ export class Config { throw 'Email verify token validity duration must be a value greater than 0.'; } } + if (emailVerifyTokenReuseIfValid && typeof emailVerifyTokenReuseIfValid !== 'boolean') { + throw 'emailVerifyTokenReuseIfValid must be a boolean value'; + } + if (emailVerifyTokenReuseIfValid && !emailVerifyTokenValidityDuration) { + throw 'You cannot use emailVerifyTokenReuseIfValid without emailVerifyTokenValidityDuration'; + } } static validateMasterKeyIps(masterKeyIps) { diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 80dda2164b..4476a7ec21 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -158,7 +158,11 @@ export class UserController extends AdaptableController { * @returns {*} */ regenerateEmailVerifyToken(user) { - const { _email_verify_token, _email_verify_token_expires_at } = user; + const { _email_verify_token } = user; + let { _email_verify_token_expires_at } = user; + if (_email_verify_token_expires_at && _email_verify_token_expires_at.__type === 'Date') { + _email_verify_token_expires_at = _email_verify_token_expires_at.iso; + } if ( this.config.emailVerifyTokenReuseIfValid && this.config.emailVerifyTokenValidityDuration && diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 7e27845621..7843cf4674 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -308,6 +308,7 @@ export class UsersRouter extends ClassesRouter { appName: req.config.appName, publicServerURL: req.config.publicServerURL, emailVerifyTokenValidityDuration: req.config.emailVerifyTokenValidityDuration, + emailVerifyTokenReuseIfValid: req.config.emailVerifyTokenReuseIfValid, }); } catch (e) { if (typeof e === 'string') { From f65046c0c69d255b5855e42de2a6be57448ba81c Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 24 Nov 2020 15:23:03 +1100 Subject: [PATCH 5/6] Tests for invalid config --- spec/EmailVerificationToken.spec.js | 39 +++++++++++++++++++++++++++++ spec/PasswordPolicy.spec.js | 39 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/spec/EmailVerificationToken.spec.js b/spec/EmailVerificationToken.spec.js index a3846815e0..50b626de0d 100644 --- a/spec/EmailVerificationToken.spec.js +++ b/spec/EmailVerificationToken.spec.js @@ -604,6 +604,45 @@ describe('Email Verification Token Expiration: ', () => { }); }); + it('should throw with invalid emailVerifyTokenReuseIfValid', async done => { + const sendEmailOptions = []; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions.push(options); + }, + sendMail: () => {}, + }; + try { + await reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5 * 60, // 5 minutes + emailVerifyTokenReuseIfValid: [], + publicServerURL: 'http://localhost:8378/1', + }); + fail('should have thrown.'); + } catch (e) { + expect(e).toBe('emailVerifyTokenReuseIfValid must be a boolean value'); + } + try { + await reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenReuseIfValid: true, + publicServerURL: 'http://localhost:8378/1', + }); + fail('should have thrown.'); + } catch (e) { + expect(e).toBe( + 'You cannot use emailVerifyTokenReuseIfValid without emailVerifyTokenValidityDuration' + ); + } + done(); + }); + it('should match codes with emailVerifyTokenReuseIfValid', async done => { let sendEmailOptions; let sendVerificationEmailCallCount = 0; diff --git a/spec/PasswordPolicy.spec.js b/spec/PasswordPolicy.spec.js index 556619aa9a..6d00ddfa28 100644 --- a/spec/PasswordPolicy.spec.js +++ b/spec/PasswordPolicy.spec.js @@ -179,6 +179,45 @@ describe('Password Policy: ', () => { done(); }); + it('should throw with invalid resetTokenReuseIfValid', async done => { + const sendEmailOptions = []; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions.push(options); + }, + sendMail: () => {}, + }; + try { + await reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenValidityDuration: 5 * 60, // 5 minutes + resetTokenReuseIfValid: [], + }, + publicServerURL: 'http://localhost:8378/1', + }); + fail('should have thrown.'); + } catch (e) { + expect(e).toBe('resetTokenReuseIfValid must be a boolean value'); + } + try { + await reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenReuseIfValid: true, + }, + publicServerURL: 'http://localhost:8378/1', + }); + fail('should have thrown.'); + } catch (e) { + expect(e).toBe('You cannot use resetTokenReuseIfValid without resetTokenValidityDuration'); + } + done(); + }); + it('should fail if passwordPolicy.resetTokenValidityDuration is not a number', done => { reconfigureServer({ appName: 'passwordPolicy', From 28da90b62d1d64dbcd9ec65c4b37b8ce3f369d9a Mon Sep 17 00:00:00 2001 From: dblythy Date: Wed, 25 Nov 2020 12:04:23 +1100 Subject: [PATCH 6/6] restart tests --- src/Controllers/UserController.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 4476a7ec21..014e8bd7ce 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -102,7 +102,6 @@ export class UserController extends AdaptableController { } if (expiresDate < new Date()) throw 'The password reset link has expired'; } - return results[0]; }); }