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:
Bhaskar Reddy Yasa
2016-11-17 22:07:51 +05:30
committed by Diwakar Cherukumilli
parent 6be9ee5491
commit cf6ce5b9a3
10 changed files with 918 additions and 16 deletions

View File

@@ -216,6 +216,7 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo
* `sessionLength` - The length of time in seconds that a session should be valid for. Defaults to 31536000 seconds (1 year).
* `revokeSessionOnPasswordReset` - When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions.
* `accountLockout` - Lock account when a malicious user is attempting to determine an account password by trial and error.
* `passwordPolicy` - Optional password policy rules to enforce.
##### Logging
@@ -277,6 +278,18 @@ var server = ParseServer({
duration: 5, // duration policy setting determines the number of minutes that a locked-out account remains locked out before automatically becoming unlocked. Set it to a value greater than 0 and less than 100000.
threshold: 3, // threshold policy setting determines the number of failed sign-in attempts that will cause a user account to be locked. Set it to an integer value greater than 0 and less than 1000.
},
// optional settings to enforce password policies
passwordPolicy: {
// Two optional settings to enforce strong passwords. Either one or both can be specified.
// If both are specified, both checks must pass to accept the password
// 1. a RegExp representing the pattern to enforce
validatorPattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{8,})/, // enforce password with at least 8 char with at least 1 lower case, 1 upper case and 1 digit
// 2. a callback function to be invoked to validate the password
validatorCallback: (password) => { return validatePassword(password) },
doNotAllowUsername: true, // optional setting to disallow username in passwords
//optional setting to set a validity duration for password reset links (in seconds)
resetTokenValidityDuration: 24*60*60, // expire after 24 hours
}
});
```

760
spec/PasswordPolicy.spec.js Normal file
View File

@@ -0,0 +1,760 @@
"use strict";
const requestp = require('request-promise');
const Config = require('../src/Config');
describe("Password Policy: ", () => {
it('should show the invalid link page if the user clicks on the password reset link after the token expires', done => {
const user = new Parse.User();
let sendEmailOptions;
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: options => {
sendEmailOptions = options;
},
sendMail: () => {
}
}
reconfigureServer({
appName: 'passwordPolicy',
emailAdapter: emailAdapter,
passwordPolicy: {
resetTokenValidityDuration: 0.5, // 0.5 second
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("testResetTokenValidity");
user.setPassword("original");
user.set('email', 'user@parse.com');
return user.signUp();
}).then(user => {
Parse.User.requestPasswordReset("user@parse.com");
}).then(() => {
// wait for a bit more than the validity duration set
setTimeout(() => {
expect(sendEmailOptions).not.toBeUndefined();
requestp.get({
uri: sendEmailOptions.link,
followRedirect: false,
simple: false,
resolveWithFullResponse: true
}).then((response) => {
expect(response.statusCode).toEqual(302);
expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html');
done();
}).catch((error) => {
fail(error);
});
}, 1000);
}).catch((err) => {
jfail(err);
done();
});
});
it('should show the reset password page if the user clicks on the password reset link before the token expires', done => {
const user = new Parse.User();
let sendEmailOptions;
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: options => {
sendEmailOptions = options;
},
sendMail: () => {
}
}
reconfigureServer({
appName: 'passwordPolicy',
emailAdapter: emailAdapter,
passwordPolicy: {
resetTokenValidityDuration: 5, // 5 seconds
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("testResetTokenValidity");
user.setPassword("original");
user.set('email', 'user@parse.com');
return user.signUp();
}).then(user => {
Parse.User.requestPasswordReset("user@parse.com");
}).then(() => {
// wait for a bit but less than the validity duration
setTimeout(() => {
expect(sendEmailOptions).not.toBeUndefined();
requestp.get({
uri: sendEmailOptions.link,
simple: false,
resolveWithFullResponse: true,
followRedirect: false
}).then((response) => {
expect(response.statusCode).toEqual(302);
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&username=testResetTokenValidity/;
expect(response.body.match(re)).not.toBe(null);
done();
}).catch((error) => {
fail(error);
});
}, 1000);
}).catch((err) => {
jfail(err);
done();
});
});
it('should fail if passwordPolicy.resetTokenValidityDuration is not a number', done => {
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
resetTokenValidityDuration: "not a number"
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
fail('passwordPolicy.resetTokenValidityDuration "not a number" test failed');
done();
}).catch(err => {
expect(err).toEqual('passwordPolicy.resetTokenValidityDuration must be a positive number');
done();
});
});
it('should fail if passwordPolicy.resetTokenValidityDuration is zero or a negative number', done => {
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
resetTokenValidityDuration: 0
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
fail('resetTokenValidityDuration negative number test failed');
done();
}).catch(err => {
expect(err).toEqual('passwordPolicy.resetTokenValidityDuration must be a positive number');
done();
});
});
it('should fail if passwordPolicy.validatorPattern setting is invalid type', done => {
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
validatorPattern: "abc" // string is not a valid setting
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
fail('passwordPolicy.validatorPattern type test failed');
done();
}).catch(err => {
expect(err).toEqual('passwordPolicy.validatorPattern must be a RegExp.');
done();
});
});
it('should fail if passwordPolicy.validatorCallback setting is invalid type', done => {
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
validatorCallback: "abc" // string is not a valid setting
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
fail('passwordPolicy.validatorCallback type test failed');
done();
}).catch(err => {
expect(err).toEqual('passwordPolicy.validatorCallback must be a function.');
done();
});
});
it('signup should fail if password does not conform to the policy enforced using validatorPattern', (done) => {
const user = new Parse.User();
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
validatorPattern: /[0-9]+/ // password should contain at least one digit
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("nodigit");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
fail('Should have failed as password does not conform to the policy.');
done();
}).catch((error) => {
expect(error.code).toEqual(142);
done();
});
})
});
it('signup should succeed if password confirms to the policy enforced using validatorPattern', (done) => {
const user = new Parse.User();
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
validatorPattern: /[0-9]+/ // password should contain at least one digit
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("1digit");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
Parse.User.logOut();
Parse.User.logIn("user1", "1digit").then(function (user) {
done();
}).catch((err) => {
jfail(err);
fail("Should be able to login");
done();
});
}).catch((error) => {
fail('Should have succeeded as password confirms to the policy.');
done();
});
})
});
it('signup should fail if password does not conform to the policy enforced using validatorCallback', (done) => {
const user = new Parse.User();
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
validatorCallback: password => false // just fail
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("any");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
fail('Should have failed as password does not conform to the policy.');
done();
}).catch((error) => {
expect(error.code).toEqual(142);
done();
});
})
});
it('signup should succeed if password confirms to the policy enforced using validatorCallback', (done) => {
const user = new Parse.User();
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
validatorCallback: password => true // never fail
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("oneUpper");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
Parse.User.logOut();
Parse.User.logIn("user1", "oneUpper").then(function (user) {
done();
}).catch((err) => {
jfail(err);
fail("Should be able to login");
done();
});
}).catch((error) => {
fail('Should have succeeded as password confirms to the policy.');
done();
});
})
});
it('signup should fail if password does not match validatorPattern but succeeds validatorCallback', (done) => {
const user = new Parse.User();
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
validatorPattern: /[A-Z]+/, // password should contain at least one UPPER case letter
validatorCallback: value => true
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("all lower");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
fail('Should have failed as password does not conform to the policy.');
done();
}).catch((error) => {
expect(error.code).toEqual(142);
done();
});
})
});
it('signup should fail if password does confirms to validatorPattern but fails validatorCallback', (done) => {
const user = new Parse.User();
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
validatorPattern: /[A-Z]+/, // password should contain at least one UPPER case letter
validatorCallback: value => false
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("oneUpper");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
fail('Should have failed as password does not conform to the policy.');
done();
}).catch((error) => {
expect(error.code).toEqual(142);
done();
});
})
});
it('signup should succeed if password confirms to both validatorPattern and validatorCallback', (done) => {
const user = new Parse.User();
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
validatorPattern: /[A-Z]+/, // password should contain at least one digit
validatorCallback: value => true
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("oneUpper");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
Parse.User.logOut();
Parse.User.logIn("user1", "oneUpper").then(function (user) {
done();
}).catch((err) => {
jfail(err);
fail("Should be able to login");
done();
});
}).catch((error) => {
fail('Should have succeeded as password confirms to the policy.');
done();
});
})
});
it('should reset password if new password confirms to password policy', done => {
var user = new Parse.User();
var emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: options => {
requestp.get({
uri: options.link,
followRedirect: false,
simple: false,
resolveWithFullResponse: true
}).then((response) => {
expect(response.statusCode).toEqual(302);
var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/;
var match = response.body.match(re);
if (!match) {
fail("should have a token");
done();
return;
}
var token = match[1];
requestp.post({
uri: "http://localhost:8378/1/apps/test/request_password_reset",
body: `new_password=has2init&token=${token}&username=user1`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
followRedirect: false,
simple: false,
resolveWithFullResponse: true
}).then((response) => {
expect(response.statusCode).toEqual(302);
expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=user1');
Parse.User.logIn("user1", "has2init").then(function (user) {
done();
}).catch((err) => {
jfail(err);
fail("should login with new password");
done();
});
}).catch((error)=> {
jfail(error);
fail("Failed to POST request password reset");
done();
});
}).catch((error)=> {
jfail(error);
fail("Failed to get the reset link");
done();
});
},
sendMail: () => {
}
}
reconfigureServer({
appName: 'passwordPolicy',
verifyUserEmails: false,
emailAdapter: emailAdapter,
passwordPolicy: {
validatorPattern: /[0-9]+/ // password should contain at least one digit
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("has 1 digit");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
Parse.User.requestPasswordReset('user1@parse.com', {
error: (err) => {
jfail(err);
fail("Reset password request should not fail");
done();
}
});
}).catch(error => {
jfail(error);
fail("signUp should not fail");
done();
});
});
});
it('should fail to reset password if the new password does not conform to password policy', done => {
var user = new Parse.User();
var emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: options => {
requestp.get({
uri: options.link,
followRedirect: false,
simple: false,
resolveWithFullResponse: true
}).then((response) => {
expect(response.statusCode).toEqual(302);
var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/;
var match = response.body.match(re);
if (!match) {
fail("should have a token");
done();
return;
}
var token = match[1];
requestp.post({
uri: "http://localhost:8378/1/apps/test/request_password_reset",
body: `new_password=hasnodigit&token=${token}&username=user1`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
followRedirect: false,
simple: false,
resolveWithFullResponse: true
}).then((response) => {
expect(response.statusCode).toEqual(302);
expect(response.body).toEqual(`Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=Password%20does%20not%20confirm%20to%20the%20Password%20Policy.&app=passwordPolicy`);
Parse.User.logIn("user1", "has 1 digit").then(function (user) {
done();
}).catch((err) => {
jfail(err);
fail("should login with old password");
done();
});
}).catch((error) => {
jfail(error);
fail("Failed to POST request password reset");
done();
});
}).catch((error) => {
jfail(error);
fail("Failed to get the reset link");
done();
});
},
sendMail: () => {
}
}
reconfigureServer({
appName: 'passwordPolicy',
verifyUserEmails: false,
emailAdapter: emailAdapter,
passwordPolicy: {
validatorPattern: /[0-9]+/ // password should contain at least one digit
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("has 1 digit");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
Parse.User.requestPasswordReset('user1@parse.com', {
error: (err) => {
jfail(err);
fail("Reset password request should not fail");
done();
}
});
}).catch(error => {
jfail(error);
fail("signUp should not fail");
done();
});
});
});
it('should fail if passwordPolicy.doNotAllowUsername is not a boolean value', (done) => {
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
doNotAllowUsername: 'no'
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
fail('passwordPolicy.doNotAllowUsername type test failed');
done();
}).catch(err => {
expect(err).toEqual('passwordPolicy.doNotAllowUsername must be a boolean value.');
done();
});
});
it('signup should fail if password contains the username and is not allowed by policy', (done) => {
const user = new Parse.User();
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
validatorPattern: /[0-9]+/,
doNotAllowUsername: true
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("@user11");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
fail('Should have failed as password contains username.');
done();
}).catch((error) => {
expect(error.code).toEqual(142);
done();
});
})
});
it('signup should succeed if password does not contain the username and is not allowed by policy', (done) => {
const user = new Parse.User();
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
doNotAllowUsername: true
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("r@nd0m");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
done();
}).catch((error) => {
fail('Should have succeeded as password does not contain username.');
done();
});
})
});
it('signup should succeed if password contains the username and it is allowed by policy', (done) => {
const user = new Parse.User();
reconfigureServer({
appName: 'passwordPolicy',
passwordPolicy: {
validatorPattern: /[0-9]+/
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("user1");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
done();
}).catch((error) => {
fail('Should have succeeded as policy allows username in password.');
done();
});
})
});
it('should fail to reset password if the new password contains username and not allowed by password policy', done => {
var user = new Parse.User();
var emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: options => {
requestp.get({
uri: options.link,
followRedirect: false,
simple: false,
resolveWithFullResponse: true
}).then((response) => {
expect(response.statusCode).toEqual(302);
var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/;
var match = response.body.match(re);
if (!match) {
fail("should have a token");
done();
return;
}
var token = match[1];
requestp.post({
uri: "http://localhost:8378/1/apps/test/request_password_reset",
body: `new_password=xuser12&token=${token}&username=user1`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
followRedirect: false,
simple: false,
resolveWithFullResponse: true
}).then((response) => {
expect(response.statusCode).toEqual(302);
expect(response.body).toEqual(`Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=Password%20does%20not%20confirm%20to%20the%20Password%20Policy.&app=passwordPolicy`);
Parse.User.logIn("user1", "r@nd0m").then(function (user) {
done();
}).catch((err) => {
jfail(err);
fail("should login with old password");
done();
});
}).catch((error) => {
jfail(error);
fail("Failed to POST request password reset");
done();
});
}).catch((error) => {
jfail(error);
fail("Failed to get the reset link");
done();
});
},
sendMail: () => {
}
}
reconfigureServer({
appName: 'passwordPolicy',
verifyUserEmails: false,
emailAdapter: emailAdapter,
passwordPolicy: {
doNotAllowUsername: true
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("r@nd0m");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
Parse.User.requestPasswordReset('user1@parse.com', {
error: (err) => {
jfail(err);
fail("Reset password request should not fail");
done();
}
});
}).catch(error => {
jfail(error);
fail("signUp should not fail");
done();
});
});
});
it('should reset password even if the new password contains user name while the policy allows', done => {
var user = new Parse.User();
var emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: options => {
requestp.get({
uri: options.link,
followRedirect: false,
simple: false,
resolveWithFullResponse: true
}).then(response => {
expect(response.statusCode).toEqual(302);
var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/;
var match = response.body.match(re);
if (!match) {
fail("should have a token");
done();
return;
}
var token = match[1];
requestp.post({
uri: "http://localhost:8378/1/apps/test/request_password_reset",
body: `new_password=uuser11&token=${token}&username=user1`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
followRedirect: false,
simple: false,
resolveWithFullResponse: true
}).then(response => {
expect(response.statusCode).toEqual(302);
expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=user1');
Parse.User.logIn("user1", "uuser11").then(function (user) {
done();
}).catch(err => {
jfail(err);
fail("should login with new password");
done();
});
}).catch(error => {
jfail(error);
fail("Failed to POST request password reset");
});
}).catch(error => {
jfail(error);
fail("Failed to get the reset link");
});
},
sendMail: () => {
}
}
reconfigureServer({
appName: 'passwordPolicy',
verifyUserEmails: false,
emailAdapter: emailAdapter,
passwordPolicy: {
validatorPattern: /[0-9]+/,
doNotAllowUsername: false
},
publicServerURL: "http://localhost:8378/1"
}).then(() => {
user.setUsername("user1");
user.setPassword("has 1 digit");
user.set('email', 'user1@parse.com');
user.signUp().then(() => {
Parse.User.requestPasswordReset('user1@parse.com', {
error: (err) => {
jfail(err);
fail("Reset password request should not fail");
done();
}
});
}).catch(error => {
jfail(error);
fail("signUp should not fail");
done();
});
});
});
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {
@@ -161,9 +177,16 @@ export class UserController extends AdaptableController {
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(() => 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, }) {

View File

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

View File

@@ -368,14 +368,50 @@ 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 defer.then(() => {
return passwordCrypto.hash(this.data.password).then((hashedPassword) => {
this.data._hashed_password = hashedPassword;
delete this.data.password;
});
});
}).then(() => {
// Check for username uniqueness

View File

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