From edb7b70ced340538363c9c25078cec063f82a675 Mon Sep 17 00:00:00 2001 From: Bhaskar Reddy Yasa Date: Mon, 21 Nov 2016 21:16:38 +0530 Subject: [PATCH] Adds password expiry support to password policy (#3068) * Adding support for password expiry policy * Renamed daysBeforeExpiry -> maxPasswordAge --- README.md | 1 + spec/PasswordPolicy.spec.js | 418 ++++++++++++++---- src/Adapters/Storage/Mongo/MongoTransform.js | 14 + .../Postgres/PostgresStorageAdapter.js | 8 +- src/Config.js | 4 + src/Controllers/DatabaseController.js | 3 +- src/RestWrite.js | 16 +- src/Routers/UsersRouter.js | 22 + 8 files changed, 397 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 43edc155..d95dbd7e 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,7 @@ var server = ParseServer({ // 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 + 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. //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 index d35f03a0..47e6f7e3 100644 --- a/spec/PasswordPolicy.spec.js +++ b/spec/PasswordPolicy.spec.js @@ -15,7 +15,7 @@ describe("Password Policy: ", () => { }, sendMail: () => { } - } + }; reconfigureServer({ appName: 'passwordPolicy', emailAdapter: emailAdapter, @@ -29,7 +29,11 @@ describe("Password Policy: ", () => { user.set('email', 'user@parse.com'); return user.signUp(); }).then(user => { - Parse.User.requestPasswordReset("user@parse.com"); + Parse.User.requestPasswordReset('user@parse.com').catch((err) => { + jfail(err); + fail("Reset password request should not fail"); + done(); + }); }).then(() => { // wait for a bit more than the validity duration set setTimeout(() => { @@ -64,7 +68,7 @@ describe("Password Policy: ", () => { }, sendMail: () => { } - } + }; reconfigureServer({ appName: 'passwordPolicy', emailAdapter: emailAdapter, @@ -78,7 +82,11 @@ describe("Password Policy: ", () => { user.set('email', 'user@parse.com'); return user.signUp(); }).then(user => { - Parse.User.requestPasswordReset("user@parse.com"); + Parse.User.requestPasswordReset('user@parse.com').catch((err) => { + jfail(err); + fail("Reset password request should not fail"); + done(); + }); }).then(() => { // wait for a bit but less than the validity duration setTimeout(() => { @@ -190,7 +198,7 @@ describe("Password Policy: ", () => { }) }); - it('signup should succeed if password confirms to the policy enforced using validatorPattern', (done) => { + it('signup should succeed if password conforms to the policy enforced using validatorPattern', (done) => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', @@ -203,16 +211,22 @@ describe("Password Policy: ", () => { 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"); + Parse.User.logOut().then(() => { + Parse.User.logIn("user1", "1digit").then(function (user) { + done(); + }).catch((err) => { + jfail(err); + fail("Should be able to login"); + done(); + }); + }).catch((error) => { + jfail(error); + fail('logout should have succeeded'); done(); }); }).catch((error) => { - fail('Should have succeeded as password confirms to the policy.'); + jfail(error); + fail('Signup should have succeeded as password conforms to the policy.'); done(); }); }) @@ -240,7 +254,7 @@ describe("Password Policy: ", () => { }) }); - it('signup should succeed if password confirms to the policy enforced using validatorCallback', (done) => { + it('signup should succeed if password conforms to the policy enforced using validatorCallback', (done) => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', @@ -253,16 +267,22 @@ describe("Password Policy: ", () => { 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"); + Parse.User.logOut().then(() => { + Parse.User.logIn("user1", "oneUpper").then(function (user) { + done(); + }).catch((err) => { + jfail(err); + fail("Should be able to login"); + done(); + }); + }).catch(error => { + jfail(error); + fail("Logout should have succeeded"); done(); }); }).catch((error) => { - fail('Should have succeeded as password confirms to the policy.'); + jfail(error); + fail('Should have succeeded as password conforms to the policy.'); done(); }); }) @@ -291,7 +311,7 @@ describe("Password Policy: ", () => { }) }); - it('signup should fail if password does confirms to validatorPattern but fails validatorCallback', (done) => { + it('signup should fail if password matches validatorPattern but fails validatorCallback', (done) => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', @@ -314,7 +334,7 @@ describe("Password Policy: ", () => { }) }); - it('signup should succeed if password confirms to both validatorPattern and validatorCallback', (done) => { + it('signup should succeed if password conforms to both validatorPattern and validatorCallback', (done) => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', @@ -328,24 +348,30 @@ describe("Password Policy: ", () => { 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"); + Parse.User.logOut().then(() => { + Parse.User.logIn("user1", "oneUpper").then(function (user) { + done(); + }).catch((err) => { + jfail(err); + fail("Should be able to login"); + done(); + }); + }).catch(error => { + jfail(error); + fail("logout should have succeeded"); done(); }); }).catch((error) => { - fail('Should have succeeded as password confirms to the policy.'); + jfail(error); + fail('Should have succeeded as password conforms to the policy.'); done(); }); }) }); - it('should reset password if new password confirms to password policy', done => { - var user = new Parse.User(); - var emailAdapter = { + it('should reset password if new password conforms to password policy', done => { + const user = new Parse.User(); + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { requestp.get({ @@ -355,14 +381,14 @@ describe("Password Policy: ", () => { 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); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.body.match(re); if (!match) { fail("should have a token"); done(); return; } - var token = match[1]; + const token = match[1]; requestp.post({ uri: "http://localhost:8378/1/apps/test/request_password_reset", @@ -397,7 +423,7 @@ describe("Password Policy: ", () => { }, sendMail: () => { } - } + }; reconfigureServer({ appName: 'passwordPolicy', verifyUserEmails: false, @@ -411,12 +437,10 @@ describe("Password Policy: ", () => { 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(); - } + Parse.User.requestPasswordReset('user1@parse.com').catch((err) => { + jfail(err); + fail("Reset password request should not fail"); + done(); }); }).catch(error => { jfail(error); @@ -427,8 +451,8 @@ describe("Password Policy: ", () => { }); it('should fail to reset password if the new password does not conform to password policy', done => { - var user = new Parse.User(); - var emailAdapter = { + const user = new Parse.User(); + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { requestp.get({ @@ -438,14 +462,14 @@ describe("Password Policy: ", () => { 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); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.body.match(re); if (!match) { fail("should have a token"); done(); return; } - var token = match[1]; + const token = match[1]; requestp.post({ uri: "http://localhost:8378/1/apps/test/request_password_reset", @@ -458,7 +482,7 @@ describe("Password Policy: ", () => { 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`); + expect(response.body).toEqual(`Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=Password%20does%20not%20meet%20the%20Password%20Policy%20requirements.&app=passwordPolicy`); Parse.User.logIn("user1", "has 1 digit").then(function (user) { done(); @@ -480,7 +504,7 @@ describe("Password Policy: ", () => { }, sendMail: () => { } - } + }; reconfigureServer({ appName: 'passwordPolicy', verifyUserEmails: false, @@ -494,12 +518,10 @@ describe("Password Policy: ", () => { 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(); - } + Parse.User.requestPasswordReset('user1@parse.com').catch((err) => { + jfail(err); + fail("Reset password request should not fail"); + done(); }); }).catch(error => { jfail(error); @@ -591,8 +613,8 @@ describe("Password Policy: ", () => { }); 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 = { + const user = new Parse.User(); + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { requestp.get({ @@ -602,14 +624,14 @@ describe("Password Policy: ", () => { 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); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.body.match(re); if (!match) { fail("should have a token"); done(); return; } - var token = match[1]; + const token = match[1]; requestp.post({ uri: "http://localhost:8378/1/apps/test/request_password_reset", @@ -622,7 +644,7 @@ describe("Password Policy: ", () => { 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`); + expect(response.body).toEqual(`Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=Password%20does%20not%20meet%20the%20Password%20Policy%20requirements.&app=passwordPolicy`); Parse.User.logIn("user1", "r@nd0m").then(function (user) { done(); @@ -645,7 +667,7 @@ describe("Password Policy: ", () => { }, sendMail: () => { } - } + }; reconfigureServer({ appName: 'passwordPolicy', verifyUserEmails: false, @@ -659,12 +681,10 @@ describe("Password Policy: ", () => { 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(); - } + Parse.User.requestPasswordReset('user1@parse.com').catch((err) => { + jfail(err); + fail("Reset password request should not fail"); + done(); }); }).catch(error => { jfail(error); @@ -675,8 +695,8 @@ describe("Password Policy: ", () => { }); it('should reset password even if the new password contains user name while the policy allows', done => { - var user = new Parse.User(); - var emailAdapter = { + const user = new Parse.User(); + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { requestp.get({ @@ -686,14 +706,14 @@ describe("Password Policy: ", () => { 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); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.body.match(re); if (!match) { fail("should have a token"); done(); return; } - var token = match[1]; + const token = match[1]; requestp.post({ uri: "http://localhost:8378/1/apps/test/request_password_reset", @@ -727,7 +747,7 @@ describe("Password Policy: ", () => { }, sendMail: () => { } - } + }; reconfigureServer({ appName: 'passwordPolicy', verifyUserEmails: false, @@ -742,16 +762,250 @@ describe("Password Policy: ", () => { 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(); - } + Parse.User.requestPasswordReset('user1@parse.com').catch((err) => { + jfail(err); + fail("Reset password request should not fail"); + done(); }); - }).catch(error => { + }); + }).catch(error => { + jfail(error); + fail("signUp should not fail"); + done(); + }); + }); + + it('should fail if passwordPolicy.maxPasswordAge is not a number', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordAge: "not a number" + }, + publicServerURL: "http://localhost:8378/1" + }).then(() => { + fail('passwordPolicy.maxPasswordAge "not a number" test failed'); + done(); + }).catch(err => { + expect(err).toEqual('passwordPolicy.maxPasswordAge must be a positive number'); + done(); + }); + }); + + it('should fail if passwordPolicy.maxPasswordAge is a negative number', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordAge: -100 + }, + publicServerURL: "http://localhost:8378/1" + }).then(() => { + fail('passwordPolicy.maxPasswordAge negative number test failed'); + done(); + }).catch(err => { + expect(err).toEqual('passwordPolicy.maxPasswordAge must be a positive number'); + done(); + }); + }); + + it('should succeed if logged in before password expires', (done) => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordAge: 1 // 1 day + }, + publicServerURL: "http://localhost:8378/1" + }).then(() => { + user.setUsername("user1"); + user.setPassword("user1"); + user.set('email', 'user1@parse.com'); + user.signUp().then((u) => { + Parse.User.logIn("user1", "user1").then((user) => { + done(); + }).catch((error) => { + jfail(error); + fail('Login should have succeeded before password expiry.'); + done(); + }); + }).catch((error) => { jfail(error); - fail("signUp should not fail"); + fail('Signup failed.'); + done(); + }); + }) + }); + + it('should fail if logged in after password expires', (done) => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordAge: 0.5/(24*60*60) // 0.5 sec + }, + publicServerURL: "http://localhost:8378/1" + }).then(() => { + user.setUsername("user1"); + user.setPassword("user1"); + user.set('email', 'user1@parse.com'); + user.signUp().then(() => { + // wait for a bit more than the validity duration set + setTimeout(() => { + Parse.User.logIn("user1", "user1").then(() => { + fail("logIn should have failed"); + done(); + }).catch((error) => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(error.message).toEqual('Your password has expired. Please reset your password.'); + done(); + }); + }, 1000); + }).catch((error) => { + jfail(error); + fail('Signup failed.'); + done(); + }); + }); + }); + + it('should apply password expiry policy to existing user upon first login after policy is enabled', (done) => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + publicServerURL: "http://localhost:8378/1" + }).then(() => { + user.setUsername("user1"); + user.setPassword("user1"); + user.set('email', 'user1@parse.com'); + user.signUp().then(() => { + Parse.User.logOut().then(() => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordAge: 0.5/(24*60*60) // 0.5 sec + }, + publicServerURL: "http://localhost:8378/1" + }).then(() => { + Parse.User.logIn("user1", "user1").then((u) => { + Parse.User.logOut().then(() => { + // wait for a bit more than the validity duration set + setTimeout(() => { + Parse.User.logIn("user1", "user1").then(() => { + fail("logIn should have failed"); + done(); + }).catch((error) => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(error.message).toEqual('Your password has expired. Please reset your password.'); + done(); + }); + }, 2000); + }).catch(error => { + jfail(error); + fail("logout should have succeeded"); + done(); + }); + }).catch((error) => { + jfail(error); + fail('Login failed.'); + done(); + }); + }); + }).catch(error => { + jfail(error); + fail("logout should have succeeded"); + done(); + }); + }).catch((error) => { + jfail(error); + fail('Signup failed.'); + done(); + }); + }); + + }); + + it('should reset password timestamp when password is reset', done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + requestp.get({ + uri: options.link, + followRedirect: false, + simple: false, + resolveWithFullResponse: true + }).then(response => { + expect(response.statusCode).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.body.match(re); + if (!match) { + fail("should have a token"); + done(); + return; + } + const 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', + emailAdapter: emailAdapter, + passwordPolicy: { + maxPasswordAge: 0.5/(24*60*60) // 0.5 sec + }, + publicServerURL: "http://localhost:8378/1" + }).then(() => { + user.setUsername("user1"); + user.setPassword("user1"); + user.set('email', 'user1@parse.com'); + user.signUp().then(() => { + // wait for a bit more than the validity duration set + setTimeout(() => { + Parse.User.logIn("user1", "user1").then(() => { + fail("logIn should have failed"); + done(); + }).catch((error) => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(error.message).toEqual('Your password has expired. Please reset your password.'); + Parse.User.requestPasswordReset('user1@parse.com').catch((err) => { + jfail(err); + fail("Reset password request should not fail"); + done(); + }); + }); + }, 1000); + }).catch((error) => { + jfail(error); + fail('Signup failed.'); done(); }); }); diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index 0b968047..f599382e 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -70,6 +70,10 @@ const transformKeyValueForUpdate = (className, restKey, restValue, parseFormatSc key = '_perishable_token_expires_at'; timeField = true; break; + case '_password_changed_at': + key = '_password_changed_at'; + timeField = true; + break; case '_rperm': case '_wperm': return {key: key, value: restValue}; @@ -180,6 +184,11 @@ function transformQueryKeyValue(className, key, value, schema) { return { key: '_perishable_token_expires_at', value: valueAsDate(value) } } break; + case '_password_changed_at': + if (valueAsDate(value)) { + return { key: '_password_changed_at', value: valueAsDate(value) } + } + break; case '_rperm': case '_wperm': case '_perishable_token': @@ -263,6 +272,10 @@ const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) => transformedValue = transformTopLevelAtom(restValue); coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue return { key: '_perishable_token_expires_at', value: coercedToDate }; + case '_password_changed_at': + transformedValue = transformTopLevelAtom(restValue); + coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue + return { key: '_password_changed_at', value: coercedToDate }; case '_failed_login_count': case '_rperm': case '_wperm': @@ -768,6 +781,7 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { case '_email_verify_token': case '_perishable_token': case '_perishable_token_expires_at': + case '_password_changed_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 1433efa3..69a33f9d 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -467,6 +467,7 @@ export class PostgresStorageAdapter { fields._failed_login_count = {type: 'Number'}; fields._perishable_token = {type: 'String'}; fields._perishable_token_expires_at = {type: 'Date'}; + fields._password_changed_at = {type: 'Date'}; } let index = 2; let relations = []; @@ -693,7 +694,8 @@ export class PostgresStorageAdapter { } if (fieldName === '_account_lockout_expires_at'|| - fieldName === '_perishable_token_expires_at') { + fieldName === '_perishable_token_expires_at'|| + fieldName === '_password_changed_at') { if (object[fieldName]) { valuesArray.push(object[fieldName].iso); } else { @@ -1075,7 +1077,9 @@ export class PostgresStorageAdapter { if (object._perishable_token_expires_at) { object._perishable_token_expires_at = { __type: 'Date', iso: object._perishable_token_expires_at.toISOString() }; } - + if (object._password_changed_at) { + object._password_changed_at = { __type: 'Date', iso: object._password_changed_at.toISOString() }; + } for (let fieldName in object) { if (object[fieldName] === null) { diff --git a/src/Config.js b/src/Config.js index dd1bfd53..d2fa2726 100644 --- a/src/Config.js +++ b/src/Config.js @@ -119,6 +119,10 @@ export class Config { static validatePasswordPolicy(passwordPolicy) { if (passwordPolicy) { + if (passwordPolicy.maxPasswordAge !== undefined && (typeof passwordPolicy.maxPasswordAge !== 'number' || passwordPolicy.maxPasswordAge < 0)) { + throw 'passwordPolicy.maxPasswordAge must be a positive number'; + } + if (passwordPolicy.resetTokenValidityDuration !== undefined && (typeof passwordPolicy.resetTokenValidityDuration !== 'number' || passwordPolicy.resetTokenValidityDuration <= 0)) { throw 'passwordPolicy.resetTokenValidityDuration must be a positive number'; } diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 110bd39b..5d15cb22 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -174,6 +174,7 @@ const filterSensitiveData = (isMaster, aclGroup, className, object) => { delete object._email_verify_token_expires_at; delete object._failed_login_count; delete object._account_lockout_expires_at; + delete object._password_changed_at; if ((aclGroup.indexOf(object.objectId) > -1)) { return object; @@ -190,7 +191,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', '_perishable_token_expires_at']; +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']; const isSpecialUpdateKey = key => { return specialKeysForUpdate.indexOf(key) >= 0; diff --git a/src/RestWrite.js b/src/RestWrite.js index 4859c48a..5d8015aa 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -371,11 +371,11 @@ RestWrite.prototype.transformUser = function() { let defer = Promise.resolve(); - // check if the password confirms to the defined password policy if configured + // check if the password conforms to the defined password policy if configured if (this.config.passwordPolicy) { - const policyError = 'Password does not confirm to the Password Policy.'; + const policyError = 'Password does not meet the Password Policy requirements.'; - // check whether the password confirms to the policy + // check whether the password conforms 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)); @@ -839,6 +839,10 @@ RestWrite.prototype.runDatabaseOperation = function() { if (this.className === '_User' && this.data.ACL) { this.data.ACL[this.query.objectId] = { read: true, write: true }; } + // update password timestamp if user password is being changed + if (this.className === '_User' && this.data._hashed_password && this.config.passwordPolicy && this.config.passwordPolicy.maxPasswordAge) { + this.data._password_changed_at = Parse._encode(new Date()); + } // Run an update return this.config.database.update(this.className, this.query, this.data, this.runOptions) .then(response => { @@ -847,7 +851,7 @@ RestWrite.prototype.runDatabaseOperation = function() { this.response = { response }; }); } else { - // Set the default ACL for the new _User + // Set the default ACL and password timestamp for the new _User if (this.className === '_User') { var ACL = this.data.ACL; // default public r/w ACL @@ -858,6 +862,10 @@ RestWrite.prototype.runDatabaseOperation = function() { // make sure the user is not locked down ACL[this.data.objectId] = { read: true, write: true }; this.data.ACL = ACL; + // password timestamp to be used when password expiry policy is enforced + if (this.config.passwordPolicy && this.config.passwordPolicy.maxPasswordAge) { + this.data._password_changed_at = Parse._encode(new Date()); + } } // Run a create diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 148b2f4d..ce476115 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -105,6 +105,28 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } + // handle password expiry policy + if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) { + let changedAt = user._password_changed_at; + + if (!changedAt) { + // password was created before expiry policy was enabled. + // simply update _User object so that it will start enforcing from now + changedAt = new Date(); + req.config.database.update('_User', {username: user.username}, + {_password_changed_at: Parse._encode(changedAt)}); + } else { + // check whether the password has expired + if (changedAt.__type == 'Date') { + changedAt = new Date(changedAt.iso); + } + // Calculate the expiry time. + const expiresAt = new Date(changedAt.getTime() + 86400000 * req.config.passwordPolicy.maxPasswordAge); + if (expiresAt < new Date()) // fail of current time is past password expiry time + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Your password has expired. Please reset your password.'); + } + } + let token = 'r:' + cryptoUtils.newToken(); user.sessionToken = token; delete user.password;