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
This commit is contained in:
dblythy
2020-11-26 04:30:52 +11:00
committed by GitHub
parent 0bf2e84f81
commit e88f2e38f9
8 changed files with 289 additions and 26 deletions

View File

@@ -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) {

View File

@@ -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);
return Promise.resolve(user);
});
const link = buildEmailLink(this.config.requestResetPasswordURL, username, token, this.config);
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));
}
return Promise.resolve(user);
}
updatePassword(username, token, password) {

View File

@@ -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',

View File

@@ -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

View File

@@ -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 */

View File

@@ -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') {