Skip to content
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

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

Merged
merged 6 commits into from
Nov 25, 2020
Merged
Show file tree
Hide file tree
Changes from all 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
108 changes: 106 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,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;
Expand Down
96 changes: 96 additions & 0 deletions spec/PasswordPolicy.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
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
81 changes: 57 additions & 24 deletions src/Controllers/UserController.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ export class UserController extends AdaptableController {
}
if (expiresDate < new Date()) throw 'The password reset link has expired';
}

return results[0];
});
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading