Skip to content

Commit 7f14cad

Browse files
committed
Reuse tokens if they haven't expired
1 parent 8763993 commit 7f14cad

File tree

6 files changed

+248
-48
lines changed

6 files changed

+248
-48
lines changed

spec/EmailVerificationToken.spec.js

Lines changed: 83 additions & 2 deletions
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,88 @@ 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
598+
);
599+
done();
600+
})
601+
.catch(error => {
602+
jfail(error);
603+
done();
604+
});
605+
});
606+
607+
it('should match codes with emailVerifyTokenReuseIfValid', done => {
608+
const user = new Parse.User();
609+
let sendEmailOptions;
610+
let sendVerificationEmailCallCount = 0;
611+
let userBeforeRequest;
612+
const emailAdapter = {
613+
sendVerificationEmail: options => {
614+
sendEmailOptions = options;
615+
sendVerificationEmailCallCount++;
616+
},
617+
sendPasswordResetEmail: () => Promise.resolve(),
618+
sendMail: () => {},
619+
};
620+
reconfigureServer({
621+
appName: 'emailVerifyToken',
622+
verifyUserEmails: true,
623+
emailAdapter: emailAdapter,
624+
emailVerifyTokenValidityDuration: 5 * 60, // 5 minutes
625+
publicServerURL: 'http://localhost:8378/1',
626+
emailVerifyTokenReuseIfValid: true,
627+
})
628+
.then(() => {
629+
user.setUsername('resends_verification_token');
630+
user.setPassword('expiringToken');
631+
user.set('email', '[email protected]');
632+
return user.signUp();
633+
})
634+
.then(() => {
635+
const config = Config.get('test');
636+
return config.database
637+
.find('_User', { username: 'resends_verification_token' })
638+
.then(results => {
639+
return results[0];
640+
});
641+
})
642+
.then(newUser => {
643+
// store this user before we make our email request
644+
userBeforeRequest = newUser;
645+
expect(sendVerificationEmailCallCount).toBe(1);
646+
647+
return request({
648+
url: 'http://localhost:8378/1/verificationEmailRequest',
649+
method: 'POST',
650+
body: {
651+
652+
},
653+
headers: {
654+
'X-Parse-Application-Id': Parse.applicationId,
655+
'X-Parse-REST-API-Key': 'rest',
656+
'Content-Type': 'application/json',
657+
},
658+
});
659+
})
660+
.then(response => {
661+
expect(response.status).toBe(200);
662+
expect(sendVerificationEmailCallCount).toBe(2);
663+
expect(sendEmailOptions).toBeDefined();
664+
665+
// query for this user again
666+
const config = Config.get('test');
667+
return config.database
668+
.find('_User', { username: 'resends_verification_token' })
669+
.then(results => {
670+
return results[0];
671+
});
672+
})
673+
.then(userAfterRequest => {
674+
// verify that our token & expiration has been changed for this new request
675+
expect(typeof userAfterRequest).toBe('object');
676+
expect(userBeforeRequest._email_verify_token).toEqual(userAfterRequest._email_verify_token);
677+
expect(userBeforeRequest._email_verify_token_expires_at).toEqual(
678+
userAfterRequest._email_verify_token_expires_at
598679
);
599680
done();
600681
})

spec/PasswordPolicy.spec.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,103 @@ describe('Password Policy: ', () => {
122122
});
123123
});
124124

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

src/Controllers/UserController.js

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -81,30 +81,27 @@ export class UserController extends AdaptableController {
8181
}
8282

8383
checkResetTokenValidity(username, token) {
84-
return this.config.database
85-
.find(
86-
'_User',
87-
{
88-
username: username,
89-
_perishable_token: token,
90-
},
91-
{ limit: 1 }
92-
)
93-
.then(results => {
94-
if (results.length != 1) {
95-
throw 'Failed to reset password: username / email / token is invalid';
96-
}
84+
let query = {
85+
username: username,
86+
_perishable_token: token,
87+
};
88+
if (!token) {
89+
query = { $or: [{ email: username }, { username, email: { $exists: false } }] };
90+
}
91+
return this.config.database.find('_User', query, { limit: 1 }).then(results => {
92+
if (results.length != 1) {
93+
throw 'Failed to reset password: username / email / token is invalid';
94+
}
9795

98-
if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) {
99-
let expiresDate = results[0]._perishable_token_expires_at;
100-
if (expiresDate && expiresDate.__type == 'Date') {
101-
expiresDate = new Date(expiresDate.iso);
102-
}
103-
if (expiresDate < new Date()) throw 'The password reset link has expired';
96+
if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) {
97+
let expiresDate = results[0]._perishable_token_expires_at;
98+
if (expiresDate && expiresDate.__type == 'Date') {
99+
expiresDate = new Date(expiresDate.iso);
104100
}
105-
106-
return results[0];
107-
});
101+
if (expiresDate < new Date()) throw 'The password reset link has expired';
102+
}
103+
return results[0];
104+
});
108105
}
109106

110107
getUserIfNeeded(user) {
@@ -158,6 +155,15 @@ export class UserController extends AdaptableController {
158155
* @returns {*}
159156
*/
160157
regenerateEmailVerifyToken(user) {
158+
const { _email_verify_token, _email_verify_token_expires_at } = user;
159+
if (
160+
this.config.emailVerifyTokenReuseIfValid &&
161+
this.config.emailVerifyTokenValidityDuration &&
162+
_email_verify_token &&
163+
new Date() < new Date(_email_verify_token_expires_at)
164+
) {
165+
return Promise.resolve();
166+
}
161167
this.setEmailVerifyToken(user);
162168
return this.config.database.update('_User', { username: user.username }, user);
163169
}
@@ -191,36 +197,42 @@ export class UserController extends AdaptableController {
191197
);
192198
}
193199

194-
sendPasswordResetEmail(email) {
200+
async sendPasswordResetEmail(email) {
195201
if (!this.adapter) {
196202
throw 'Trying to send a reset password but no adapter is set';
197203
// TODO: No adapter?
198204
}
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
209-
);
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));
205+
let user;
206+
if (
207+
this.config.passwordPolicy.resetTokenReuseIfValid &&
208+
this.config.resetTokenValidityDuration
209+
) {
210+
try {
211+
user = await this.checkResetTokenValidity(email);
212+
} catch (e) {
213+
/* */
220214
}
215+
}
216+
if (!user || !user._perishable_token) {
217+
user = await this.setPasswordResetToken(email);
218+
}
219+
const token = encodeURIComponent(user._perishable_token);
220+
const username = encodeURIComponent(user.username);
221+
222+
const link = buildEmailLink(this.config.requestResetPasswordURL, username, token, this.config);
223+
const options = {
224+
appName: this.config.appName,
225+
link: link,
226+
user: inflate('_User', user),
227+
};
221228

222-
return Promise.resolve(user);
223-
});
229+
if (this.adapter.sendPasswordResetEmail) {
230+
this.adapter.sendPasswordResetEmail(options);
231+
} else {
232+
this.adapter.sendMail(this.defaultResetPasswordEmail(options));
233+
}
234+
235+
return Promise.resolve(user);
224236
}
225237

226238
updatePassword(username, token, password) {

src/Options/Definitions.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ module.exports.ParseServerOptions = {
125125
help: 'Adapter module for email sending',
126126
action: parsers.moduleOrObjectParser,
127127
},
128+
emailVerifyTokenReuseIfValid: {
129+
env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_REUSE_IF_VALID',
130+
help: 'an existing password reset token should be reused when a password reset is requested',
131+
action: parsers.booleanParser,
132+
default: false,
133+
},
128134
emailVerifyTokenValidityDuration: {
129135
env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_VALIDITY_DURATION',
130136
help: 'Email verification token validity duration, in seconds',

src/Options/docs.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
* @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.
2424
* @property {String} dotNetKey Key for Unity and .Net SDK
2525
* @property {Adapter<MailAdapter>} emailAdapter Adapter module for email sending
26+
* @property {Boolean} emailVerifyTokenReuseIfValid an existing password reset token should be reused when a password reset is requested
2627
* @property {Number} emailVerifyTokenValidityDuration Email verification token validity duration, in seconds
2728
* @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true
2829
* @property {Boolean} enableExpressErrorHandler Enables the default express error handler for all errors

src/Options/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ export interface ParseServerOptions {
124124
preventLoginWithUnverifiedEmail: ?boolean;
125125
/* Email verification token validity duration, in seconds */
126126
emailVerifyTokenValidityDuration: ?number;
127+
/* an existing password reset token should be reused when resend verification is requested
128+
:DEFAULT: false */
129+
emailVerifyTokenReuseIfValid: ?boolean;
127130
/* account lockout policy for failed login attempts */
128131
accountLockout: ?any;
129132
/* Password policy for enforcing password related rules */

0 commit comments

Comments
 (0)