From cf6ce5b9a3b25ec6d95caf292e9dc3f01f6e6211 Mon Sep 17 00:00:00 2001 From: Bhaskar Reddy Yasa Date: Thu, 17 Nov 2016 22:07:51 +0530 Subject: [PATCH] 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 --- README.md | 13 + spec/PasswordPolicy.spec.js | 760 ++++++++++++++++++ src/Adapters/Storage/Mongo/MongoTransform.js | 14 + .../Postgres/PostgresStorageAdapter.js | 8 +- src/Config.js | 43 +- src/Controllers/DatabaseController.js | 3 +- src/Controllers/UserController.js | 41 +- src/ParseServer.js | 3 + src/RestWrite.js | 44 +- src/cli/definitions/parse-server.js | 5 + 10 files changed, 918 insertions(+), 16 deletions(-) create mode 100644 spec/PasswordPolicy.spec.js diff --git a/README.md b/README.md index 5b01aba2bd..43edc155a4 100644 --- a/README.md +++ b/README.md @@ -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 + } }); ``` diff --git a/spec/PasswordPolicy.spec.js b/spec/PasswordPolicy.spec.js new file mode 100644 index 0000000000..d35f03a0c3 --- /dev/null +++ b/spec/PasswordPolicy.spec.js @@ -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(); + }); + }); + }); + +}) diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index 7d14421272..f3de0b440b 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -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': diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 5870e33502..74bc1935fb 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -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) { diff --git a/src/Config.js b/src/Config.js index 016a3fe0ac..dd1bfd538c 100644 --- a/src/Config.js +++ b/src/Config.js @@ -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; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 986a71d5b7..110bd39b5b 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -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; diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index fbdf707a4e..d54f036b45 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -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; diff --git a/src/ParseServer.js b/src/ParseServer.js index faa544dffb..4cd4fd3d9a 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -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. diff --git a/src/RestWrite.js b/src/RestWrite.js index 14105e6d89..4859c48a4f 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -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(() => { diff --git a/src/cli/definitions/parse-server.js b/src/cli/definitions/parse-server.js index 54b49aefb2..dd401d470c 100644 --- a/src/cli/definitions/parse-server.js +++ b/src/cli/definitions/parse-server.js @@ -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"