Skip to content

Feature: Reuse tokens if they haven't expired #7017

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Nov 25, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions spec/EmailVerificationToken.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
})
Expand All @@ -604,6 +604,71 @@ describe('Email Verification Token Expiration: ', () => {
});
});

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', '[email protected]');
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: '[email protected]',
},
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;
Expand Down
57 changes: 57 additions & 0 deletions spec/PasswordPolicy.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,63 @@ 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', '[email protected]');
await user.signUp();
await Parse.User.requestPasswordReset('[email protected]');
await Parse.User.requestPasswordReset('[email protected]');
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', '[email protected]');
await user.signUp();
await Parse.User.requestPasswordReset('[email protected]');
await Parse.User.requestPasswordReset('[email protected]');
expect(sendEmailOptions[0].link).toBe(sendEmailOptions[1].link);
done();
});

it('should fail if passwordPolicy.resetTokenValidityDuration is not a number', done => {
reconfigureServer({
appName: 'passwordPolicy',
Expand Down
19 changes: 19 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class Config {
readOnlyMasterKey,
allowHeaders,
idempotencyOptions,
emailVerifyTokenReuseIfValid,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
Expand All @@ -82,6 +83,7 @@ export class Config {
appName,
publicServerURL,
emailVerifyTokenValidityDuration,
emailVerifyTokenReuseIfValid,
});
}

Expand Down Expand Up @@ -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';
}
}
}

Expand All @@ -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.';
Expand All @@ -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) {
Expand Down
80 changes: 57 additions & 23 deletions src/Controllers/UserController.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,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);
}
Expand Down Expand Up @@ -191,36 +204,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) {
Expand Down
6 changes: 6 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/Options/docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<MailAdapter>} 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
Expand Down
3 changes: 3 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
1 change: 1 addition & 0 deletions src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down