Adding support for optional Password Policy (#3032)
* adds resetTokenValidityDuration setting * adds a validator to validate password that can be used to enforce strong passwords * adds unit tests for passwordPolicy.validator * adds unit tests to to fail reset password function if password is not in a valid format * updates README.md for passwordPolicy * prevents duplicate check for password validator in updateUserPassword * adds optional setting to disallow username in password * updates test cases to use fdescribe instead of describe * updates test cases to use request-promise instead of request * adds ability to use a RegExp or Callback function or both for a passwordPolicy.validator * expect username parameter in redirect to password_reset_success * adds support for _perishable_token_expires_at in postgres
This commit is contained in:
committed by
Diwakar Cherukumilli
parent
6be9ee5491
commit
cf6ce5b9a3
@@ -66,6 +66,10 @@ const transformKeyValueForUpdate = (className, restKey, restValue, parseFormatSc
|
||||
case '_failed_login_count':
|
||||
key = '_failed_login_count';
|
||||
break;
|
||||
case '_perishable_token_expires_at':
|
||||
key = '_perishable_token_expires_at';
|
||||
timeField = true;
|
||||
break;
|
||||
case '_rperm':
|
||||
case '_wperm':
|
||||
return {key: key, value: restValue};
|
||||
@@ -171,6 +175,11 @@ function transformQueryKeyValue(className, key, value, schema) {
|
||||
case '_failed_login_count':
|
||||
return {key, value};
|
||||
case 'sessionToken': return {key: '_session_token', value}
|
||||
case '_perishable_token_expires_at':
|
||||
if (valueAsDate(value)) {
|
||||
return { key: '_perishable_token_expires_at', value: valueAsDate(value) }
|
||||
}
|
||||
break;
|
||||
case '_rperm':
|
||||
case '_wperm':
|
||||
case '_perishable_token':
|
||||
@@ -250,6 +259,10 @@ const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) =>
|
||||
transformedValue = transformTopLevelAtom(restValue);
|
||||
coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue
|
||||
return {key: '_account_lockout_expires_at', value: coercedToDate};
|
||||
case '_perishable_token_expires_at':
|
||||
transformedValue = transformTopLevelAtom(restValue);
|
||||
coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue
|
||||
return { key: '_perishable_token_expires_at', value: coercedToDate };
|
||||
case '_failed_login_count':
|
||||
case '_rperm':
|
||||
case '_wperm':
|
||||
@@ -748,6 +761,7 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => {
|
||||
break;
|
||||
case '_email_verify_token':
|
||||
case '_perishable_token':
|
||||
case '_perishable_token_expires_at':
|
||||
case '_tombstone':
|
||||
case '_email_verify_token_expires_at':
|
||||
case '_account_lockout_expires_at':
|
||||
|
||||
@@ -466,6 +466,7 @@ export class PostgresStorageAdapter {
|
||||
fields._account_lockout_expires_at = {type: 'Date'};
|
||||
fields._failed_login_count = {type: 'Number'};
|
||||
fields._perishable_token = {type: 'String'};
|
||||
fields._perishable_token_expires_at = {type: 'Date'};
|
||||
}
|
||||
let index = 2;
|
||||
let relations = [];
|
||||
@@ -691,7 +692,8 @@ export class PostgresStorageAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldName === '_account_lockout_expires_at') {
|
||||
if (fieldName === '_account_lockout_expires_at'||
|
||||
fieldName === '_perishable_token_expires_at') {
|
||||
if (object[fieldName]) {
|
||||
valuesArray.push(object[fieldName].iso);
|
||||
} else {
|
||||
@@ -1068,6 +1070,10 @@ export class PostgresStorageAdapter {
|
||||
if (object._account_lockout_expires_at) {
|
||||
object._account_lockout_expires_at = { __type: 'Date', iso: object._account_lockout_expires_at.toISOString() };
|
||||
}
|
||||
if (object._perishable_token_expires_at) {
|
||||
object._perishable_token_expires_at = { __type: 'Date', iso: object._perishable_token_expires_at.toISOString() };
|
||||
}
|
||||
|
||||
|
||||
for (let fieldName in object) {
|
||||
if (object[fieldName] === null) {
|
||||
|
||||
@@ -50,6 +50,7 @@ export class Config {
|
||||
this.preventLoginWithUnverifiedEmail = cacheInfo.preventLoginWithUnverifiedEmail;
|
||||
this.emailVerifyTokenValidityDuration = cacheInfo.emailVerifyTokenValidityDuration;
|
||||
this.accountLockout = cacheInfo.accountLockout;
|
||||
this.passwordPolicy = cacheInfo.passwordPolicy;
|
||||
this.appName = cacheInfo.appName;
|
||||
|
||||
this.analyticsController = cacheInfo.analyticsController;
|
||||
@@ -79,7 +80,8 @@ export class Config {
|
||||
expireInactiveSessions,
|
||||
sessionLength,
|
||||
emailVerifyTokenValidityDuration,
|
||||
accountLockout
|
||||
accountLockout,
|
||||
passwordPolicy
|
||||
}) {
|
||||
const emailAdapter = userController.adapter;
|
||||
if (verifyUserEmails) {
|
||||
@@ -88,6 +90,8 @@ export class Config {
|
||||
|
||||
this.validateAccountLockoutPolicy(accountLockout);
|
||||
|
||||
this.validatePasswordPolicy(passwordPolicy);
|
||||
|
||||
if (typeof revokeSessionOnPasswordReset !== 'boolean') {
|
||||
throw 'revokeSessionOnPasswordReset must be a boolean value';
|
||||
}
|
||||
@@ -113,6 +117,35 @@ export class Config {
|
||||
}
|
||||
}
|
||||
|
||||
static validatePasswordPolicy(passwordPolicy) {
|
||||
if (passwordPolicy) {
|
||||
if (passwordPolicy.resetTokenValidityDuration !== undefined && (typeof passwordPolicy.resetTokenValidityDuration !== 'number' || passwordPolicy.resetTokenValidityDuration <= 0)) {
|
||||
throw 'passwordPolicy.resetTokenValidityDuration must be a positive number';
|
||||
}
|
||||
|
||||
if(passwordPolicy.validatorPattern && !(passwordPolicy.validatorPattern instanceof RegExp)) {
|
||||
throw 'passwordPolicy.validatorPattern must be a RegExp.';
|
||||
}
|
||||
|
||||
if(passwordPolicy.validatorCallback && typeof passwordPolicy.validatorCallback !== 'function' ) {
|
||||
throw 'passwordPolicy.validatorCallback must be a function.';
|
||||
}
|
||||
|
||||
if(passwordPolicy.doNotAllowUsername && typeof passwordPolicy.doNotAllowUsername !== 'boolean') {
|
||||
throw 'passwordPolicy.doNotAllowUsername must be a boolean value.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if the passwordPolicy.validatorPattern is configured then setup a callback to process the pattern
|
||||
static setupPasswordValidator(passwordPolicy) {
|
||||
if (passwordPolicy && passwordPolicy.validatorPattern) {
|
||||
passwordPolicy.patternValidator = (value) => {
|
||||
return passwordPolicy.validatorPattern.test(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static validateEmailConfiguration({emailAdapter, appName, publicServerURL, emailVerifyTokenValidityDuration}) {
|
||||
if (!emailAdapter) {
|
||||
throw 'An emailAdapter is required for e-mail verification and password resets.';
|
||||
@@ -163,6 +196,14 @@ export class Config {
|
||||
return new Date(now.getTime() + (this.emailVerifyTokenValidityDuration*1000));
|
||||
}
|
||||
|
||||
generatePasswordResetTokenExpiresAt() {
|
||||
if (!this.passwordPolicy || !this.passwordPolicy.resetTokenValidityDuration) {
|
||||
return undefined;
|
||||
}
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() + (this.passwordPolicy.resetTokenValidityDuration * 1000));
|
||||
}
|
||||
|
||||
generateSessionExpiresAt() {
|
||||
if (!this.expireInactiveSessions) {
|
||||
return undefined;
|
||||
|
||||
@@ -169,6 +169,7 @@ const filterSensitiveData = (isMaster, aclGroup, className, object) => {
|
||||
}
|
||||
delete object._email_verify_token;
|
||||
delete object._perishable_token;
|
||||
delete object._perishable_token_expires_at;
|
||||
delete object._tombstone;
|
||||
delete object._email_verify_token_expires_at;
|
||||
delete object._failed_login_count;
|
||||
@@ -189,7 +190,7 @@ const filterSensitiveData = (isMaster, aclGroup, className, object) => {
|
||||
// acl: a list of strings. If the object to be updated has an ACL,
|
||||
// one of the provided strings must provide the caller with
|
||||
// write permissions.
|
||||
const specialKeysForUpdate = ['_hashed_password', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at', '_account_lockout_expires_at', '_failed_login_count'];
|
||||
const specialKeysForUpdate = ['_hashed_password', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at', '_account_lockout_expires_at', '_failed_login_count', '_perishable_token_expires_at'];
|
||||
|
||||
const isSpecialUpdateKey = key => {
|
||||
return specialKeysForUpdate.indexOf(key) >= 0;
|
||||
|
||||
@@ -77,6 +77,16 @@ export class UserController extends AdaptableController {
|
||||
if (results.length != 1) {
|
||||
throw undefined;
|
||||
}
|
||||
|
||||
if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) {
|
||||
let expiresDate = results[0]._perishable_token_expires_at;
|
||||
if (expiresDate && expiresDate.__type == 'Date') {
|
||||
expiresDate = new Date(expiresDate.iso);
|
||||
}
|
||||
if (expiresDate < new Date())
|
||||
throw 'The password reset link has expired';
|
||||
}
|
||||
|
||||
return results[0];
|
||||
});
|
||||
}
|
||||
@@ -125,7 +135,13 @@ export class UserController extends AdaptableController {
|
||||
}
|
||||
|
||||
setPasswordResetToken(email) {
|
||||
return this.config.database.update('_User', { $or: [{email}, {username: email, email: {$exists: false}}] }, { _perishable_token: randomString(25) }, {}, true)
|
||||
const token = { _perishable_token: randomString(25) };
|
||||
|
||||
if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) {
|
||||
token._perishable_token_expires_at = Parse._encode(this.config.generatePasswordResetTokenExpiresAt());
|
||||
}
|
||||
|
||||
return this.config.database.update('_User', { $or: [{email}, {username: email, email: {$exists: false}}] }, token, {}, true)
|
||||
}
|
||||
|
||||
sendPasswordResetEmail(email) {
|
||||
@@ -159,11 +175,18 @@ export class UserController extends AdaptableController {
|
||||
|
||||
updatePassword(username, token, password, config) {
|
||||
return this.checkResetTokenValidity(username, token)
|
||||
.then(user => updateUserPassword(user.objectId, password, this.config))
|
||||
// clear reset password token
|
||||
.then(() => this.config.database.update('_User', { username }, {
|
||||
_perishable_token: {__op: 'Delete'}
|
||||
}));
|
||||
.then(user => updateUserPassword(user.objectId, password, this.config))
|
||||
// clear reset password token
|
||||
.then(() => this.config.database.update('_User', {username}, {
|
||||
_perishable_token: {__op: 'Delete'},
|
||||
_perishable_token_expires_at: {__op: 'Delete'}
|
||||
})).catch((error) => {
|
||||
if (error.message) { // in case of Parse.Error, fail with the error message only
|
||||
return Promise.reject(error.message);
|
||||
} else {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
defaultVerificationEmail({link, user, appName, }) {
|
||||
@@ -189,9 +212,9 @@ export class UserController extends AdaptableController {
|
||||
|
||||
// Mark this private
|
||||
function updateUserPassword(userId, password, config) {
|
||||
return rest.update(config, Auth.master(config), '_User', userId, {
|
||||
password: password
|
||||
});
|
||||
return rest.update(config, Auth.master(config), '_User', userId, {
|
||||
password: password
|
||||
});
|
||||
}
|
||||
|
||||
export default UserController;
|
||||
|
||||
@@ -125,6 +125,7 @@ class ParseServer {
|
||||
preventLoginWithUnverifiedEmail = defaults.preventLoginWithUnverifiedEmail,
|
||||
emailVerifyTokenValidityDuration,
|
||||
accountLockout,
|
||||
passwordPolicy,
|
||||
cacheAdapter,
|
||||
emailAdapter,
|
||||
publicServerURL,
|
||||
@@ -210,6 +211,7 @@ class ParseServer {
|
||||
preventLoginWithUnverifiedEmail: preventLoginWithUnverifiedEmail,
|
||||
emailVerifyTokenValidityDuration: emailVerifyTokenValidityDuration,
|
||||
accountLockout: accountLockout,
|
||||
passwordPolicy: passwordPolicy,
|
||||
allowClientClassCreation: allowClientClassCreation,
|
||||
authDataManager: authDataManager(oauth, enableAnonymousUsers),
|
||||
appName: appName,
|
||||
@@ -233,6 +235,7 @@ class ParseServer {
|
||||
|
||||
Config.validate(AppCache.get(appId));
|
||||
this.config = AppCache.get(appId);
|
||||
Config.setupPasswordValidator(this.config.passwordPolicy);
|
||||
hooksController.load();
|
||||
|
||||
// Note: Tests will start to fail if any validation happens after this is called.
|
||||
|
||||
@@ -368,13 +368,49 @@ RestWrite.prototype.transformUser = function() {
|
||||
if (!this.data.password) {
|
||||
return;
|
||||
}
|
||||
if (this.query && !this.auth.isMaster ) {
|
||||
|
||||
let defer = Promise.resolve();
|
||||
|
||||
// check if the password confirms to the defined password policy if configured
|
||||
if (this.config.passwordPolicy) {
|
||||
const policyError = 'Password does not confirm to the Password Policy.';
|
||||
|
||||
// check whether the password confirms to the policy
|
||||
if (this.config.passwordPolicy.patternValidator && !this.config.passwordPolicy.patternValidator(this.data.password) ||
|
||||
this.config.passwordPolicy.validatorCallback && !this.config.passwordPolicy.validatorCallback(this.data.password)) {
|
||||
return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, policyError));
|
||||
}
|
||||
|
||||
// check whether password contain username
|
||||
if (this.config.passwordPolicy.doNotAllowUsername === true) {
|
||||
if (this.data.username) { // username is not passed during password reset
|
||||
if (this.data.password.indexOf(this.data.username) >= 0)
|
||||
return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, policyError));
|
||||
|
||||
} else { // retrieve the User object using objectId during password reset
|
||||
defer = this.config.database.find('_User', {objectId: this.objectId()})
|
||||
.then(results => {
|
||||
if (results.length != 1) {
|
||||
throw undefined;
|
||||
}
|
||||
if (this.data.password.indexOf(results[0].username) >= 0)
|
||||
return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, policyError));
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.query && !this.auth.isMaster) {
|
||||
this.storage['clearSessions'] = true;
|
||||
this.storage['generateNewSession'] = true;
|
||||
}
|
||||
return passwordCrypto.hash(this.data.password).then((hashedPassword) => {
|
||||
this.data._hashed_password = hashedPassword;
|
||||
delete this.data.password;
|
||||
|
||||
return defer.then(() => {
|
||||
return passwordCrypto.hash(this.data.password).then((hashedPassword) => {
|
||||
this.data._hashed_password = hashedPassword;
|
||||
delete this.data.password;
|
||||
});
|
||||
});
|
||||
|
||||
}).then(() => {
|
||||
|
||||
@@ -136,6 +136,11 @@ export default {
|
||||
help: "account lockout policy for failed login attempts",
|
||||
action: objectParser
|
||||
},
|
||||
"passwordPolicy": {
|
||||
env: "PARSE_SERVER_PASSWORD_POLICY",
|
||||
help: "Password policy for enforcing password related rules",
|
||||
action: objectParser
|
||||
},
|
||||
"appName": {
|
||||
env: "PARSE_SERVER_APP_NAME",
|
||||
help: "Sets the app name"
|
||||
|
||||
Reference in New Issue
Block a user