diff --git a/spec/EmailVerificationToken.spec.js b/spec/EmailVerificationToken.spec.js index b109f7f244..50b626de0d 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,7 @@ 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(); }) @@ -604,6 +604,110 @@ 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; + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendVerificationEmailCallCount++; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5 * 60, // 5 minutes + publicServerURL: 'http://localhost:8378/1', + emailVerifyTokenReuseIfValid: true, + }); + 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 => { const user = new Parse.User(); let sendEmailOptions; diff --git a/spec/PasswordPolicy.spec.js b/spec/PasswordPolicy.spec.js index 790992262b..6d00ddfa28 100644 --- a/spec/PasswordPolicy.spec.js +++ b/spec/PasswordPolicy.spec.js @@ -122,6 +122,102 @@ describe('Password Policy: ', () => { }); }); + it('should not keep reset token by default', async done => { + const sendEmailOptions = []; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions.push(options); + }, + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenValidityDuration: 5 * 60, // 5 minutes + }, + publicServerURL: 'http://localhost:8378/1', + }); + 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', async done => { + const sendEmailOptions = []; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions.push(options); + }, + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenValidityDuration: 5 * 60, // 5 minutes + resetTokenReuseIfValid: true, + }, + publicServerURL: 'http://localhost:8378/1', + }); + 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 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', 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 d853f7feaa..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]; }); } @@ -158,6 +157,19 @@ export class UserController extends AdaptableController { * @returns {*} */ regenerateEmailVerifyToken(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 && + _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 +203,57 @@ 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 + let user; + if ( + this.config.passwordPolicy && + this.config.passwordPolicy.resetTokenReuseIfValid && + this.config.passwordPolicy.resetTokenValidityDuration + ) { + 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 } ); - 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)); + 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) { + 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 */ 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') {