Skip to content

Commit 79e74a3

Browse files
bhaskaryasaRafael Santos
authored andcommitted
Adds password history support to passwordPolicy (parse-community#3102)
* password history support in passwordPolicy * Refactor RestWrite.transformUser * fix eslint issues
1 parent baf0e3c commit 79e74a3

File tree

7 files changed

+414
-95
lines changed

7 files changed

+414
-95
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ var server = ParseServer({
288288
validatorCallback: (password) => { return validatePassword(password) },
289289
doNotAllowUsername: true, // optional setting to disallow username in passwords
290290
maxPasswordAge: 90, // optional setting in days for password expiry. Login fails if user does not reset the password within this period after signup/last reset.
291+
maxPasswordHistory: 5, // optional setting to prevent reuse of previous n passwords. Maximum value that can be specified is 20. Not specifying it or specifying 0 will not enforce history.
291292
//optional setting to set a validity duration for password reset links (in seconds)
292293
resetTokenValidityDuration: 24*60*60, // expire after 24 hours
293294
}

spec/PasswordPolicy.spec.js

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,4 +1010,242 @@ describe("Password Policy: ", () => {
10101010
});
10111011
});
10121012

1013+
it('should fail if passwordPolicy.maxPasswordHistory is not a number', done => {
1014+
reconfigureServer({
1015+
appName: 'passwordPolicy',
1016+
passwordPolicy: {
1017+
maxPasswordHistory: "not a number"
1018+
},
1019+
publicServerURL: "http://localhost:8378/1"
1020+
}).then(() => {
1021+
fail('passwordPolicy.maxPasswordHistory "not a number" test failed');
1022+
done();
1023+
}).catch(err => {
1024+
expect(err).toEqual('passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20');
1025+
done();
1026+
});
1027+
});
1028+
1029+
it('should fail if passwordPolicy.maxPasswordHistory is a negative number', done => {
1030+
reconfigureServer({
1031+
appName: 'passwordPolicy',
1032+
passwordPolicy: {
1033+
maxPasswordHistory: -10
1034+
},
1035+
publicServerURL: "http://localhost:8378/1"
1036+
}).then(() => {
1037+
fail('passwordPolicy.maxPasswordHistory negative number test failed');
1038+
done();
1039+
}).catch(err => {
1040+
expect(err).toEqual('passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20');
1041+
done();
1042+
});
1043+
});
1044+
1045+
it('should fail if passwordPolicy.maxPasswordHistory is greater than 20', done => {
1046+
reconfigureServer({
1047+
appName: 'passwordPolicy',
1048+
passwordPolicy: {
1049+
maxPasswordHistory: 21
1050+
},
1051+
publicServerURL: "http://localhost:8378/1"
1052+
}).then(() => {
1053+
fail('passwordPolicy.maxPasswordHistory negative number test failed');
1054+
done();
1055+
}).catch(err => {
1056+
expect(err).toEqual('passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20');
1057+
done();
1058+
});
1059+
});
1060+
1061+
it('should fail to reset if the new password is same as the last password', done => {
1062+
const user = new Parse.User();
1063+
const emailAdapter = {
1064+
sendVerificationEmail: () => Promise.resolve(),
1065+
sendPasswordResetEmail: options => {
1066+
requestp.get({
1067+
uri: options.link,
1068+
followRedirect: false,
1069+
simple: false,
1070+
resolveWithFullResponse: true
1071+
}).then(response => {
1072+
expect(response.statusCode).toEqual(302);
1073+
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/;
1074+
const match = response.body.match(re);
1075+
if (!match) {
1076+
fail("should have a token");
1077+
return Promise.reject("Invalid password link");
1078+
}
1079+
return Promise.resolve(match[1]); // token
1080+
}).then(token => {
1081+
return new Promise((resolve, reject) => {
1082+
requestp.post({
1083+
uri: "http://localhost:8378/1/apps/test/request_password_reset",
1084+
body: `new_password=user1&token=${token}&username=user1`,
1085+
headers: {
1086+
'Content-Type': 'application/x-www-form-urlencoded'
1087+
},
1088+
followRedirect: false,
1089+
simple: false,
1090+
resolveWithFullResponse: true
1091+
}).then(response => {
1092+
resolve([response, token]);
1093+
}).catch(error => {
1094+
reject(error);
1095+
});
1096+
});
1097+
}).then(data => {
1098+
const response = data[0];
1099+
const token = data[1];
1100+
expect(response.statusCode).toEqual(302);
1101+
expect(response.body).toEqual(`Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=New%20password%20should%20not%20be%20the%20same%20as%20last%201%20passwords.&app=passwordPolicy`);
1102+
done();
1103+
return Promise.resolve();
1104+
}).catch(error => {
1105+
jfail(error);
1106+
fail("Repeat password test failed");
1107+
done();
1108+
});
1109+
},
1110+
sendMail: () => {
1111+
}
1112+
};
1113+
reconfigureServer({
1114+
appName: 'passwordPolicy',
1115+
verifyUserEmails: false,
1116+
emailAdapter: emailAdapter,
1117+
passwordPolicy: {
1118+
maxPasswordHistory: 1
1119+
},
1120+
publicServerURL: "http://localhost:8378/1"
1121+
}).then(() => {
1122+
user.setUsername("user1");
1123+
user.setPassword("user1");
1124+
user.set('email', '[email protected]');
1125+
user.signUp().then(() => {
1126+
return Parse.User.logOut();
1127+
}).then(() => {
1128+
return Parse.User.requestPasswordReset('[email protected]');
1129+
}).catch(error => {
1130+
jfail(error);
1131+
fail("SignUp or reset request failed");
1132+
done();
1133+
});
1134+
});
1135+
});
1136+
1137+
1138+
it('should fail if the new password is same as the previous one', done => {
1139+
const user = new Parse.User();
1140+
1141+
reconfigureServer({
1142+
appName: 'passwordPolicy',
1143+
verifyUserEmails: false,
1144+
passwordPolicy: {
1145+
maxPasswordHistory: 5
1146+
},
1147+
publicServerURL: "http://localhost:8378/1"
1148+
}).then(() => {
1149+
user.setUsername("user1");
1150+
user.setPassword("user1");
1151+
user.set('email', '[email protected]');
1152+
user.signUp().then(() => {
1153+
// try to set the same password as the previous one
1154+
user.setPassword('user1');
1155+
return user.save();
1156+
}).then(() => {
1157+
fail("should have failed because the new password is same as the old");
1158+
done();
1159+
}).catch(error => {
1160+
expect(error.message).toEqual('New password should not be the same as last 5 passwords.');
1161+
expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR);
1162+
done();
1163+
});
1164+
});
1165+
});
1166+
1167+
it('should fail if the new password is same as the 5th oldest one and policy does not allow the previous 5', done => {
1168+
const user = new Parse.User();
1169+
1170+
reconfigureServer({
1171+
appName: 'passwordPolicy',
1172+
verifyUserEmails: false,
1173+
passwordPolicy: {
1174+
maxPasswordHistory: 5
1175+
},
1176+
publicServerURL: "http://localhost:8378/1"
1177+
}).then(() => {
1178+
user.setUsername("user1");
1179+
user.setPassword("user1");
1180+
user.set('email', '[email protected]');
1181+
user.signUp().then(() => {
1182+
// build history
1183+
user.setPassword('user2');
1184+
return user.save();
1185+
}).then(() => {
1186+
user.setPassword('user3');
1187+
return user.save();
1188+
}).then(() => {
1189+
user.setPassword('user4');
1190+
return user.save();
1191+
}).then(() => {
1192+
user.setPassword('user5');
1193+
return user.save();
1194+
}).then(() => {
1195+
// set the same password as the initial one
1196+
user.setPassword('user1');
1197+
return user.save();
1198+
}).then(() => {
1199+
fail("should have failed because the new password is same as the old");
1200+
done();
1201+
}).catch(error => {
1202+
expect(error.message).toEqual('New password should not be the same as last 5 passwords.');
1203+
expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR);
1204+
done();
1205+
});
1206+
});
1207+
});
1208+
1209+
it('should succeed if the new password is same as the 6th oldest one and policy does not allow only previous 5', done => {
1210+
const user = new Parse.User();
1211+
1212+
reconfigureServer({
1213+
appName: 'passwordPolicy',
1214+
verifyUserEmails: false,
1215+
passwordPolicy: {
1216+
maxPasswordHistory: 5
1217+
},
1218+
publicServerURL: "http://localhost:8378/1"
1219+
}).then(() => {
1220+
user.setUsername("user1");
1221+
user.setPassword("user1");
1222+
user.set('email', '[email protected]');
1223+
user.signUp().then(() => {
1224+
// build history
1225+
user.setPassword('user2');
1226+
return user.save();
1227+
}).then(() => {
1228+
user.setPassword('user3');
1229+
return user.save();
1230+
}).then(() => {
1231+
user.setPassword('user4');
1232+
return user.save();
1233+
}).then(() => {
1234+
user.setPassword('user5');
1235+
return user.save();
1236+
}).then(() => {
1237+
user.setPassword('user6'); // this pushes initial password out of history
1238+
return user.save();
1239+
}).then(() => {
1240+
// set the same password as the initial one
1241+
user.setPassword('user1');
1242+
return user.save();
1243+
}).then(() => {
1244+
done();
1245+
}).catch(() => {
1246+
fail("should have succeeded because the new password is not in history");
1247+
done();
1248+
});
1249+
});
1250+
});
10131251
})

src/Adapters/Storage/Mongo/MongoTransform.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,7 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => {
787787
case '_email_verify_token_expires_at':
788788
case '_account_lockout_expires_at':
789789
case '_failed_login_count':
790+
case '_password_history':
790791
// Those keys will be deleted if needed in the DB Controller
791792
restObject[key] = mongoObject[key];
792793
break;

src/Adapters/Storage/Postgres/PostgresStorageAdapter.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const toPostgresSchema = (schema) => {
108108
schema.fields._rperm = {type: 'Array', contents: {type: 'String'}}
109109
if (schema.className === '_User') {
110110
schema.fields._hashed_password = {type: 'String'};
111+
schema.fields._password_history = {type: 'Array'};
111112
}
112113
return schema;
113114
}
@@ -471,6 +472,7 @@ export class PostgresStorageAdapter {
471472
fields._perishable_token = {type: 'String'};
472473
fields._perishable_token_expires_at = {type: 'Date'};
473474
fields._password_changed_at = {type: 'Date'};
475+
fields._password_history = { type: 'Array'};
474476
}
475477
let index = 2;
476478
let relations = [];
@@ -683,7 +685,8 @@ export class PostgresStorageAdapter {
683685
if (!schema.fields[fieldName] && className === '_User') {
684686
if (fieldName === '_email_verify_token' ||
685687
fieldName === '_failed_login_count' ||
686-
fieldName === '_perishable_token') {
688+
fieldName === '_perishable_token' ||
689+
fieldName === '_password_history'){
687690
valuesArray.push(object[fieldName]);
688691
}
689692

src/Config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ export class Config {
138138
if(passwordPolicy.doNotAllowUsername && typeof passwordPolicy.doNotAllowUsername !== 'boolean') {
139139
throw 'passwordPolicy.doNotAllowUsername must be a boolean value.';
140140
}
141+
142+
if (passwordPolicy.maxPasswordHistory && (!Number.isInteger(passwordPolicy.maxPasswordHistory) || passwordPolicy.maxPasswordHistory <= 0 || passwordPolicy.maxPasswordHistory > 20)) {
143+
throw 'passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20';
144+
}
141145
}
142146
}
143147

src/Controllers/DatabaseController.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ const filterSensitiveData = (isMaster, aclGroup, className, object) => {
190190
// acl: a list of strings. If the object to be updated has an ACL,
191191
// one of the provided strings must provide the caller with
192192
// write permissions.
193-
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', '_password_changed_at'];
193+
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', '_password_changed_at', '_password_history'];
194194

195195
const isSpecialUpdateKey = key => {
196196
return specialKeysForUpdate.indexOf(key) >= 0;

0 commit comments

Comments
 (0)