Skip to content

Commit

Permalink
Merge branch 'add-account-unlock-on-password-reset' into deploy-debug
Browse files Browse the repository at this point in the history
* add-account-unlock-on-password-reset:
  moved changelog entry to correct position
  Added docs entry
  added changelog entry
  added account policy option
  added account unlock on password reset
  fix: upgrade ws from 7.4.1 to 7.4.2 (parse-community#7132)
  Supporting patterns in classNames for Live Queries (parse-community#7131)
  add api mail adapter to mail adapter list (parse-community#7126)

# Conflicts:
#	CHANGELOG.md
#	src/Config.js
#	src/Options/Definitions.js
  • Loading branch information
mtrezza committed Jan 28, 2021
2 parents a2378fa + fbc8748 commit 97b6b07
Show file tree
Hide file tree
Showing 13 changed files with 201 additions and 10 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ __BREAKING CHANGES:__
- NEW: Added file upload restriction. File upload is now only allowed for authenticated users by default for improved security. To allow file upload also for Anonymous Users or Public, set the `fileUpload` parameter in the [Parse Server Options](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html). [#7071](https://github.com/parse-community/parse-server/pull/7071). Thanks to [dblythy](https://github.com/dblythy).
___
- NEW (EXPERIMENTAL): Added page localization for password reset and email verification. **Caution, this is an experimental feature that may not be appropriate for production.** [#6891](https://github.com/parse-community/parse-server/issues/6891). Thanks to [Manuel Trezza](https://github.com/mtrezza).
- IMPROVE: Added new account lockout policy option `accountLockout.unlockOnPasswordReset` to automatically unlock account on password reset. [#7146](https://github.com/parse-community/parse-server/pull/7146). Thanks to [Manuel Trezza](https://github.com/mtrezza).
- IMPROVE: Optimize queries on classes with pointer permissions. [#7061](https://github.com/parse-community/parse-server/pull/7061). Thanks to [Pedro Diaz](https://github.com/pdiaz)
- FIX: request.context for afterFind triggers. [#7078](https://github.com/parse-community/parse-server/pull/7078). Thanks to [dblythy](https://github.com/dblythy)
- NEW: Added convenience method Parse.Cloud.sendEmail(...) to send email via email adapter in Cloud Code. [#7089](https://github.com/parse-community/parse-server/pull/7089). Thanks to [dblythy](https://github.com/dblythy)
- FIX: Winston Logger interpolating stdout to console [#7114](https://github.com/parse-community/parse-server/pull/7114). Thanks to [dplewis](https://github.com/dplewis)
- NEW: LiveQuery support for $and, $nor, $containedBy, $geoWithin, $geoIntersects queries [#7113](https://github.com/parse-community/parse-server/pull/7113). Thanks to [dplewis](https://github.com/dplewis)
- NEW: Supporting patterns in LiveQuery server's config parameter `classNames` [#7131](https://github.com/parse-community/parse-server/pull/7131). Thanks to [Nes-si](https://github.com/Nes-si)

### 4.5.0
[Full Changelog](https://github.com/parse-community/parse-server/compare/4.4.0...4.5.0)
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ var server = ParseServer({
accountLockout: {
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.
unlockOnPasswordReset: true, // Is true if the account lock should be removed after a successful password reset. Default: false.
}
},
// optional settings to enforce password policies
passwordPolicy: {
Expand Down Expand Up @@ -347,6 +349,7 @@ You can also use other email adapters contributed by the community such as:
- [parse-server-mailjet-adapter](https://www.npmjs.com/package/parse-server-mailjet-adapter)
- [simple-parse-smtp-adapter](https://www.npmjs.com/package/simple-parse-smtp-adapter)
- [parse-server-generic-email-adapter](https://www.npmjs.com/package/parse-server-generic-email-adapter)
- [parse-server-api-mail-adapter](https://www.npmjs.com/package/parse-server-api-mail-adapter)

### Custom Pages

Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"uuid": "8.3.2",
"winston": "3.3.3",
"winston-daily-rotate-file": "4.5.0",
"ws": "7.4.1"
"ws": "7.4.2"
},
"devDependencies": {
"@babel/cli": "7.10.0",
Expand Down
117 changes: 117 additions & 0 deletions spec/AccountLockoutPolicy.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use strict';

const Config = require('../lib/Config');
const Definitions = require('../lib/Options/Definitions');
const request = require('../lib/request');

const loginWithWrongCredentialsShouldFail = function (username, password) {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -340,3 +342,118 @@ describe('Account Lockout Policy: ', () => {
});
});
});

describe('lockout with password reset option', () => {
let sendPasswordResetEmail;

async function setup(options = {}) {
const accountLockout = Object.assign(
{
duration: 10000,
threshold: 1,
}, options
);
const config = {
appName: 'exampleApp',
accountLockout: accountLockout,
publicServerURL: 'http://localhost:8378/1',
emailAdapter: {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {},
},
};
await reconfigureServer(config);

sendPasswordResetEmail = spyOn(config.emailAdapter, 'sendPasswordResetEmail').and.callThrough();
}

it('accepts valid unlockOnPasswordReset option', async () => {
const values = [true, false];

for (const value of values) {
await expectAsync(setup({ unlockOnPasswordReset: value })).toBeResolved();
}
});

it('rejects invalid unlockOnPasswordReset option', async () => {
const values = ["a", 0, {}, [], null];

for (const value of values) {
await expectAsync(setup({ unlockOnPasswordReset: value })).toBeRejected();
}
});

it('uses default value if unlockOnPasswordReset is not set', async () => {
await expectAsync(setup({ unlockOnPasswordReset: undefined })).toBeResolved();

const parseConfig = Config.get(Parse.applicationId);
expect(parseConfig.accountLockout.unlockOnPasswordReset).toBe(Definitions.AccountLockoutOptions.unlockOnPasswordReset.default);
});

it('allow login for locked account after password reset', async () => {
await setup({ unlockOnPasswordReset: true });
const config = Config.get(Parse.applicationId);

const user = new Parse.User();
const username = 'exampleUsername';
const password = 'examplePassword';
user.setUsername(username);
user.setPassword(password);
user.setEmail('mail@example.com');
await user.signUp();

await expectAsync(Parse.User.logIn(username, 'incorrectPassword')).toBeRejected();
await expectAsync(Parse.User.logIn(username, password)).toBeRejected();

await Parse.User.requestPasswordReset(user.getEmail());
const link = sendPasswordResetEmail.calls.all()[0].args[0].link;
const linkUrl = new URL(link);
const token = linkUrl.searchParams.get('token');
const newPassword = 'newPassword';
await request({
method: 'POST',
url: `${config.publicServerURL}/apps/test/request_password_reset`,
body: `new_password=${newPassword}&token=${token}&username=${username}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
followRedirects: false,
});

await expectAsync(Parse.User.logIn(username, newPassword)).toBeResolved();
});

it('reject login for locked account after password reset (default)', async () => {
await setup();
const config = Config.get(Parse.applicationId);

const user = new Parse.User();
const username = 'exampleUsername';
const password = 'examplePassword';
user.setUsername(username);
user.setPassword(password);
user.setEmail('mail@example.com');
await user.signUp();

await expectAsync(Parse.User.logIn(username, 'incorrectPassword')).toBeRejected();
await expectAsync(Parse.User.logIn(username, password)).toBeRejected();

await Parse.User.requestPasswordReset(user.getEmail());
const link = sendPasswordResetEmail.calls.all()[0].args[0].link;
const linkUrl = new URL(link);
const token = linkUrl.searchParams.get('token');
const newPassword = 'newPassword';
await request({
method: 'POST',
url: `${config.publicServerURL}/apps/test/request_password_reset`,
body: `new_password=${newPassword}&token=${token}&username=${username}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
followRedirects: false,
});

await expectAsync(Parse.User.logIn(username, newPassword)).toBeRejected();
});
});
23 changes: 23 additions & 0 deletions spec/ParseLiveQuery.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,29 @@ describe('ParseLiveQuery', function () {
await object.save();
});

it('can use patterns in className', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['Test.*'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const object = new TestObject();
await object.save();

const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('update', object => {
expect(object.get('foo')).toBe('bar');
done();
});
object.set({ foo: 'bar' });
await object.save();
});

it('expect afterEvent create', async done => {
await reconfigureServer({
liveQuery: {
Expand Down
17 changes: 17 additions & 0 deletions src/AccountLockout.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,23 @@ export class AccountLockout {
}
});
}

/**
* Removes the account lockout.
*/
unlockAccount() {
if (!this._config.accountLockout || !this._config.accountLockout.unlockOnPasswordReset) {
return Promise.resolve();
}
return this._config.database.update(
'_User',
{ username: this._user.username },
{
_failed_login_count: { __op: 'Delete' },
_account_lockout_expires_at: { __op: 'Delete' },
},
);
}
}

export default AccountLockout;
7 changes: 7 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
IdempotencyOptions,
FileUploadOptions,
PagesOptions,
AccountLockoutOptions,
} from './Options/Definitions';
import { isBoolean, isString } from 'lodash';

Expand Down Expand Up @@ -186,6 +187,12 @@ export class Config {
) {
throw 'Account lockout threshold should be an integer greater than 0 and less than 1000';
}

if (accountLockout.unlockOnPasswordReset === undefined) {
accountLockout.unlockOnPasswordReset = AccountLockoutOptions.unlockOnPasswordReset.default;
} else if (!isBoolean(accountLockout.unlockOnPasswordReset)) {
throw 'Parse Server option accountLockout.unlockOnPasswordReset must be a boolean.';
}
}
}

Expand Down
11 changes: 9 additions & 2 deletions src/Controllers/LiveQueryController.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export class LiveQueryController {
if (!config || !config.classNames) {
this.classNames = new Set();
} else if (config.classNames instanceof Array) {
this.classNames = new Set(config.classNames);
const classNames = config.classNames
.map(name => new RegExp("^" + name + "$"));
this.classNames = new Set(classNames);
} else {
throw 'liveQuery.classes should be an array of string';
}
Expand Down Expand Up @@ -43,7 +45,12 @@ export class LiveQueryController {
}

hasLiveQuery(className: string): boolean {
return this.classNames.has(className);
for (const name of this.classNames) {
if (name.test(className)) {
return true;
}
}
return false;
}

_makePublisherRequest(currentObject: any, originalObject: any, classLevelPermissions: ?any): any {
Expand Down
13 changes: 9 additions & 4 deletions src/Controllers/UserController.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import AdaptableController from './AdaptableController';
import MailAdapter from '../Adapters/Email/MailAdapter';
import rest from '../rest';
import Parse from 'parse/node';
import AccountLockout from '../AccountLockout';

var RestQuery = require('../RestQuery');
var Auth = require('../Auth');
Expand Down Expand Up @@ -258,7 +259,11 @@ export class UserController extends AdaptableController {

updatePassword(username, token, password) {
return this.checkResetTokenValidity(username, token)
.then(user => updateUserPassword(user.objectId, password, this.config))
.then(user => updateUserPassword(user, password, this.config))
.then(user => {
const accountLockoutPolicy = new AccountLockout(user, this.config);
return accountLockoutPolicy.unlockAccount();
})
.catch(error => {
if (error && error.message) {
// in case of Parse.Error, fail with the error message only
Expand Down Expand Up @@ -302,16 +307,16 @@ export class UserController extends AdaptableController {
}

// Mark this private
function updateUserPassword(userId, password, config) {
function updateUserPassword(user, password, config) {
return rest.update(
config,
Auth.master(config),
'_User',
{ objectId: userId },
{ objectId: user.objectId },
{
password: password,
}
);
).then(() => user);
}

function buildEmailLink(destination, username, token, config) {
Expand Down
6 changes: 6 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,12 @@ module.exports.AccountLockoutOptions = {
"env": "PARSE_SERVER_ACCOUNT_LOCKOUT_THRESHOLD",
"help": "number of failed sign-in attempts that will cause a user account to be locked",
"action": parsers.numberParser("threshold")
},
"unlockOnPasswordReset": {
"env": "PARSE_SERVER_ACCOUNT_LOCKOUT_UNLOCK_ON_PASSWORD_RESET",
"help": "Is true if the account lock should be removed after a successful password reset.",
"action": parsers.booleanParser,
"default": false
}
};
module.exports.PasswordPolicyOptions = {
Expand Down
1 change: 1 addition & 0 deletions src/Options/docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
* @interface AccountLockoutOptions
* @property {Number} duration number of minutes that a locked-out account remains locked out before automatically becoming unlocked.
* @property {Number} threshold number of failed sign-in attempts that will cause a user account to be locked
* @property {Boolean} unlockOnPasswordReset Is true if the account lock should be removed after a successful password reset.
*/

/**
Expand Down
3 changes: 3 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,9 @@ export interface AccountLockoutOptions {
duration: ?number;
/* number of failed sign-in attempts that will cause a user account to be locked */
threshold: ?number;
/* Is true if the account lock should be removed after a successful password reset.
:DEFAULT: false */
unlockOnPasswordReset: ?boolean;
}

export interface PasswordPolicyOptions {
Expand Down

0 comments on commit 97b6b07

Please sign in to comment.