Skip to content

Commit e88f2e3

Browse files
authored
Feature: Reuse tokens if they haven't expired (#7017)
* Reuse tokens if they haven't expired * Fix failing tests * Update UserController.js * Update tests * Tests for invalid config * restart tests
1 parent 0bf2e84 commit e88f2e3

File tree

8 files changed

+289
-26
lines changed

8 files changed

+289
-26
lines changed

spec/EmailVerificationToken.spec.js

+106-2
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ describe('Email Verification Token Expiration: ', () => {
510510
userAfterEmailReset._email_verify_token
511511
);
512512
expect(userBeforeEmailReset._email_verify_token_expires_at).not.toEqual(
513-
userAfterEmailReset.__email_verify_token_expires_at
513+
userAfterEmailReset._email_verify_token_expires_at
514514
);
515515
expect(sendEmailOptions).toBeDefined();
516516
done();
@@ -594,7 +594,7 @@ describe('Email Verification Token Expiration: ', () => {
594594
userAfterRequest._email_verify_token
595595
);
596596
expect(userBeforeRequest._email_verify_token_expires_at).not.toEqual(
597-
userAfterRequest.__email_verify_token_expires_at
597+
userAfterRequest._email_verify_token_expires_at
598598
);
599599
done();
600600
})
@@ -604,6 +604,110 @@ describe('Email Verification Token Expiration: ', () => {
604604
});
605605
});
606606

607+
it('should throw with invalid emailVerifyTokenReuseIfValid', async done => {
608+
const sendEmailOptions = [];
609+
const emailAdapter = {
610+
sendVerificationEmail: () => Promise.resolve(),
611+
sendPasswordResetEmail: options => {
612+
sendEmailOptions.push(options);
613+
},
614+
sendMail: () => {},
615+
};
616+
try {
617+
await reconfigureServer({
618+
appName: 'passwordPolicy',
619+
verifyUserEmails: true,
620+
emailAdapter: emailAdapter,
621+
emailVerifyTokenValidityDuration: 5 * 60, // 5 minutes
622+
emailVerifyTokenReuseIfValid: [],
623+
publicServerURL: 'http://localhost:8378/1',
624+
});
625+
fail('should have thrown.');
626+
} catch (e) {
627+
expect(e).toBe('emailVerifyTokenReuseIfValid must be a boolean value');
628+
}
629+
try {
630+
await reconfigureServer({
631+
appName: 'passwordPolicy',
632+
verifyUserEmails: true,
633+
emailAdapter: emailAdapter,
634+
emailVerifyTokenReuseIfValid: true,
635+
publicServerURL: 'http://localhost:8378/1',
636+
});
637+
fail('should have thrown.');
638+
} catch (e) {
639+
expect(e).toBe(
640+
'You cannot use emailVerifyTokenReuseIfValid without emailVerifyTokenValidityDuration'
641+
);
642+
}
643+
done();
644+
});
645+
646+
it('should match codes with emailVerifyTokenReuseIfValid', async done => {
647+
let sendEmailOptions;
648+
let sendVerificationEmailCallCount = 0;
649+
const emailAdapter = {
650+
sendVerificationEmail: options => {
651+
sendEmailOptions = options;
652+
sendVerificationEmailCallCount++;
653+
},
654+
sendPasswordResetEmail: () => Promise.resolve(),
655+
sendMail: () => {},
656+
};
657+
await reconfigureServer({
658+
appName: 'emailVerifyToken',
659+
verifyUserEmails: true,
660+
emailAdapter: emailAdapter,
661+
emailVerifyTokenValidityDuration: 5 * 60, // 5 minutes
662+
publicServerURL: 'http://localhost:8378/1',
663+
emailVerifyTokenReuseIfValid: true,
664+
});
665+
const user = new Parse.User();
666+
user.setUsername('resends_verification_token');
667+
user.setPassword('expiringToken');
668+
user.set('email', '[email protected]');
669+
await user.signUp();
670+
671+
const config = Config.get('test');
672+
const [userBeforeRequest] = await config.database.find('_User', {
673+
username: 'resends_verification_token',
674+
});
675+
// store this user before we make our email request
676+
expect(sendVerificationEmailCallCount).toBe(1);
677+
await new Promise(resolve => {
678+
setTimeout(() => {
679+
resolve();
680+
}, 1000);
681+
});
682+
const response = await request({
683+
url: 'http://localhost:8378/1/verificationEmailRequest',
684+
method: 'POST',
685+
body: {
686+
687+
},
688+
headers: {
689+
'X-Parse-Application-Id': Parse.applicationId,
690+
'X-Parse-REST-API-Key': 'rest',
691+
'Content-Type': 'application/json',
692+
},
693+
});
694+
expect(response.status).toBe(200);
695+
expect(sendVerificationEmailCallCount).toBe(2);
696+
expect(sendEmailOptions).toBeDefined();
697+
698+
const [userAfterRequest] = await config.database.find('_User', {
699+
username: 'resends_verification_token',
700+
});
701+
702+
// verify that our token & expiration has been changed for this new request
703+
expect(typeof userAfterRequest).toBe('object');
704+
expect(userBeforeRequest._email_verify_token).toEqual(userAfterRequest._email_verify_token);
705+
expect(userBeforeRequest._email_verify_token_expires_at).toEqual(
706+
userAfterRequest._email_verify_token_expires_at
707+
);
708+
done();
709+
});
710+
607711
it('should not send a new verification email when a resend is requested and the user is VERIFIED', done => {
608712
const user = new Parse.User();
609713
let sendEmailOptions;

spec/PasswordPolicy.spec.js

+96
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,102 @@ describe('Password Policy: ', () => {
122122
});
123123
});
124124

125+
it('should not keep reset token by default', async done => {
126+
const sendEmailOptions = [];
127+
const emailAdapter = {
128+
sendVerificationEmail: () => Promise.resolve(),
129+
sendPasswordResetEmail: options => {
130+
sendEmailOptions.push(options);
131+
},
132+
sendMail: () => {},
133+
};
134+
await reconfigureServer({
135+
appName: 'passwordPolicy',
136+
emailAdapter: emailAdapter,
137+
passwordPolicy: {
138+
resetTokenValidityDuration: 5 * 60, // 5 minutes
139+
},
140+
publicServerURL: 'http://localhost:8378/1',
141+
});
142+
const user = new Parse.User();
143+
user.setUsername('testResetTokenValidity');
144+
user.setPassword('original');
145+
user.set('email', '[email protected]');
146+
await user.signUp();
147+
await Parse.User.requestPasswordReset('[email protected]');
148+
await Parse.User.requestPasswordReset('[email protected]');
149+
expect(sendEmailOptions[0].link).not.toBe(sendEmailOptions[1].link);
150+
done();
151+
});
152+
153+
it('should keep reset token with resetTokenReuseIfValid', async done => {
154+
const sendEmailOptions = [];
155+
const emailAdapter = {
156+
sendVerificationEmail: () => Promise.resolve(),
157+
sendPasswordResetEmail: options => {
158+
sendEmailOptions.push(options);
159+
},
160+
sendMail: () => {},
161+
};
162+
await reconfigureServer({
163+
appName: 'passwordPolicy',
164+
emailAdapter: emailAdapter,
165+
passwordPolicy: {
166+
resetTokenValidityDuration: 5 * 60, // 5 minutes
167+
resetTokenReuseIfValid: true,
168+
},
169+
publicServerURL: 'http://localhost:8378/1',
170+
});
171+
const user = new Parse.User();
172+
user.setUsername('testResetTokenValidity');
173+
user.setPassword('original');
174+
user.set('email', '[email protected]');
175+
await user.signUp();
176+
await Parse.User.requestPasswordReset('[email protected]');
177+
await Parse.User.requestPasswordReset('[email protected]');
178+
expect(sendEmailOptions[0].link).toBe(sendEmailOptions[1].link);
179+
done();
180+
});
181+
182+
it('should throw with invalid resetTokenReuseIfValid', async done => {
183+
const sendEmailOptions = [];
184+
const emailAdapter = {
185+
sendVerificationEmail: () => Promise.resolve(),
186+
sendPasswordResetEmail: options => {
187+
sendEmailOptions.push(options);
188+
},
189+
sendMail: () => {},
190+
};
191+
try {
192+
await reconfigureServer({
193+
appName: 'passwordPolicy',
194+
emailAdapter: emailAdapter,
195+
passwordPolicy: {
196+
resetTokenValidityDuration: 5 * 60, // 5 minutes
197+
resetTokenReuseIfValid: [],
198+
},
199+
publicServerURL: 'http://localhost:8378/1',
200+
});
201+
fail('should have thrown.');
202+
} catch (e) {
203+
expect(e).toBe('resetTokenReuseIfValid must be a boolean value');
204+
}
205+
try {
206+
await reconfigureServer({
207+
appName: 'passwordPolicy',
208+
emailAdapter: emailAdapter,
209+
passwordPolicy: {
210+
resetTokenReuseIfValid: true,
211+
},
212+
publicServerURL: 'http://localhost:8378/1',
213+
});
214+
fail('should have thrown.');
215+
} catch (e) {
216+
expect(e).toBe('You cannot use resetTokenReuseIfValid without resetTokenValidityDuration');
217+
}
218+
done();
219+
});
220+
125221
it('should fail if passwordPolicy.resetTokenValidityDuration is not a number', done => {
126222
reconfigureServer({
127223
appName: 'passwordPolicy',

src/Config.js

+19
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export class Config {
7070
readOnlyMasterKey,
7171
allowHeaders,
7272
idempotencyOptions,
73+
emailVerifyTokenReuseIfValid,
7374
}) {
7475
if (masterKey === readOnlyMasterKey) {
7576
throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -82,6 +83,7 @@ export class Config {
8283
appName,
8384
publicServerURL,
8485
emailVerifyTokenValidityDuration,
86+
emailVerifyTokenReuseIfValid,
8587
});
8688
}
8789

@@ -190,6 +192,16 @@ export class Config {
190192
) {
191193
throw 'passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20';
192194
}
195+
196+
if (
197+
passwordPolicy.resetTokenReuseIfValid &&
198+
typeof passwordPolicy.resetTokenReuseIfValid !== 'boolean'
199+
) {
200+
throw 'resetTokenReuseIfValid must be a boolean value';
201+
}
202+
if (passwordPolicy.resetTokenReuseIfValid && !passwordPolicy.resetTokenValidityDuration) {
203+
throw 'You cannot use resetTokenReuseIfValid without resetTokenValidityDuration';
204+
}
193205
}
194206
}
195207

@@ -207,6 +219,7 @@ export class Config {
207219
appName,
208220
publicServerURL,
209221
emailVerifyTokenValidityDuration,
222+
emailVerifyTokenReuseIfValid,
210223
}) {
211224
if (!emailAdapter) {
212225
throw 'An emailAdapter is required for e-mail verification and password resets.';
@@ -224,6 +237,12 @@ export class Config {
224237
throw 'Email verify token validity duration must be a value greater than 0.';
225238
}
226239
}
240+
if (emailVerifyTokenReuseIfValid && typeof emailVerifyTokenReuseIfValid !== 'boolean') {
241+
throw 'emailVerifyTokenReuseIfValid must be a boolean value';
242+
}
243+
if (emailVerifyTokenReuseIfValid && !emailVerifyTokenValidityDuration) {
244+
throw 'You cannot use emailVerifyTokenReuseIfValid without emailVerifyTokenValidityDuration';
245+
}
227246
}
228247

229248
static validateMasterKeyIps(masterKeyIps) {

src/Controllers/UserController.js

+57-24
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ export class UserController extends AdaptableController {
102102
}
103103
if (expiresDate < new Date()) throw 'The password reset link has expired';
104104
}
105-
106105
return results[0];
107106
});
108107
}
@@ -158,6 +157,19 @@ export class UserController extends AdaptableController {
158157
* @returns {*}
159158
*/
160159
regenerateEmailVerifyToken(user) {
160+
const { _email_verify_token } = user;
161+
let { _email_verify_token_expires_at } = user;
162+
if (_email_verify_token_expires_at && _email_verify_token_expires_at.__type === 'Date') {
163+
_email_verify_token_expires_at = _email_verify_token_expires_at.iso;
164+
}
165+
if (
166+
this.config.emailVerifyTokenReuseIfValid &&
167+
this.config.emailVerifyTokenValidityDuration &&
168+
_email_verify_token &&
169+
new Date() < new Date(_email_verify_token_expires_at)
170+
) {
171+
return Promise.resolve();
172+
}
161173
this.setEmailVerifyToken(user);
162174
return this.config.database.update('_User', { username: user.username }, user);
163175
}
@@ -191,36 +203,57 @@ export class UserController extends AdaptableController {
191203
);
192204
}
193205

194-
sendPasswordResetEmail(email) {
206+
async sendPasswordResetEmail(email) {
195207
if (!this.adapter) {
196208
throw 'Trying to send a reset password but no adapter is set';
197209
// TODO: No adapter?
198210
}
199-
200-
return this.setPasswordResetToken(email).then(user => {
201-
const token = encodeURIComponent(user._perishable_token);
202-
const username = encodeURIComponent(user.username);
203-
204-
const link = buildEmailLink(
205-
this.config.requestResetPasswordURL,
206-
username,
207-
token,
208-
this.config
211+
let user;
212+
if (
213+
this.config.passwordPolicy &&
214+
this.config.passwordPolicy.resetTokenReuseIfValid &&
215+
this.config.passwordPolicy.resetTokenValidityDuration
216+
) {
217+
const results = await this.config.database.find(
218+
'_User',
219+
{
220+
$or: [
221+
{ email, _perishable_token: { $exists: true } },
222+
{ username: email, email: { $exists: false }, _perishable_token: { $exists: true } },
223+
],
224+
},
225+
{ limit: 1 }
209226
);
210-
const options = {
211-
appName: this.config.appName,
212-
link: link,
213-
user: inflate('_User', user),
214-
};
215-
216-
if (this.adapter.sendPasswordResetEmail) {
217-
this.adapter.sendPasswordResetEmail(options);
218-
} else {
219-
this.adapter.sendMail(this.defaultResetPasswordEmail(options));
227+
if (results.length == 1) {
228+
let expiresDate = results[0]._perishable_token_expires_at;
229+
if (expiresDate && expiresDate.__type == 'Date') {
230+
expiresDate = new Date(expiresDate.iso);
231+
}
232+
if (expiresDate > new Date()) {
233+
user = results[0];
234+
}
220235
}
236+
}
237+
if (!user || !user._perishable_token) {
238+
user = await this.setPasswordResetToken(email);
239+
}
240+
const token = encodeURIComponent(user._perishable_token);
241+
const username = encodeURIComponent(user.username);
242+
243+
const link = buildEmailLink(this.config.requestResetPasswordURL, username, token, this.config);
244+
const options = {
245+
appName: this.config.appName,
246+
link: link,
247+
user: inflate('_User', user),
248+
};
221249

222-
return Promise.resolve(user);
223-
});
250+
if (this.adapter.sendPasswordResetEmail) {
251+
this.adapter.sendPasswordResetEmail(options);
252+
} else {
253+
this.adapter.sendMail(this.defaultResetPasswordEmail(options));
254+
}
255+
256+
return Promise.resolve(user);
224257
}
225258

226259
updatePassword(username, token, password) {

0 commit comments

Comments
 (0)