Skip to content

Commit 08b2ea4

Browse files
authored
Add account unlock on password reset (#7146)
* added account unlock on password reset * added account policy option * added changelog entry * Added docs entry * moved changelog entry to correct position * improved tests to ensure requesting password reset email does not unlock account * run prettier
1 parent 25fb576 commit 08b2ea4

File tree

9 files changed

+171
-4
lines changed

9 files changed

+171
-4
lines changed

Diff for: CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
__BREAKING CHANGES:__
77
- 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).
88
___
9+
- 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).
910
- 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)
1011
- FIX: request.context for afterFind triggers. [#7078](https://github.com/parse-community/parse-server/pull/7078). Thanks to [dblythy](https://github.com/dblythy)
1112
- 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)

Diff for: README.md

+2
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,8 @@ var server = ParseServer({
307307
accountLockout: {
308308
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.
309309
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.
310+
unlockOnPasswordReset: true, // Is true if the account lock should be removed after a successful password reset. Default: false.
311+
}
310312
},
311313
// optional settings to enforce password policies
312314
passwordPolicy: {

Diff for: spec/AccountLockoutPolicy.spec.js

+124
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use strict';
22

33
const Config = require('../lib/Config');
4+
const Definitions = require('../lib/Options/Definitions');
5+
const request = require('../lib/request');
46

57
const loginWithWrongCredentialsShouldFail = function (username, password) {
68
return new Promise((resolve, reject) => {
@@ -340,3 +342,125 @@ describe('Account Lockout Policy: ', () => {
340342
});
341343
});
342344
});
345+
346+
describe('lockout with password reset option', () => {
347+
let sendPasswordResetEmail;
348+
349+
async function setup(options = {}) {
350+
const accountLockout = Object.assign(
351+
{
352+
duration: 10000,
353+
threshold: 1,
354+
},
355+
options
356+
);
357+
const config = {
358+
appName: 'exampleApp',
359+
accountLockout: accountLockout,
360+
publicServerURL: 'http://localhost:8378/1',
361+
emailAdapter: {
362+
sendVerificationEmail: () => Promise.resolve(),
363+
sendPasswordResetEmail: () => Promise.resolve(),
364+
sendMail: () => {},
365+
},
366+
};
367+
await reconfigureServer(config);
368+
369+
sendPasswordResetEmail = spyOn(config.emailAdapter, 'sendPasswordResetEmail').and.callThrough();
370+
}
371+
372+
it('accepts valid unlockOnPasswordReset option', async () => {
373+
const values = [true, false];
374+
375+
for (const value of values) {
376+
await expectAsync(setup({ unlockOnPasswordReset: value })).toBeResolved();
377+
}
378+
});
379+
380+
it('rejects invalid unlockOnPasswordReset option', async () => {
381+
const values = ['a', 0, {}, [], null];
382+
383+
for (const value of values) {
384+
await expectAsync(setup({ unlockOnPasswordReset: value })).toBeRejected();
385+
}
386+
});
387+
388+
it('uses default value if unlockOnPasswordReset is not set', async () => {
389+
await expectAsync(setup({ unlockOnPasswordReset: undefined })).toBeResolved();
390+
391+
const parseConfig = Config.get(Parse.applicationId);
392+
expect(parseConfig.accountLockout.unlockOnPasswordReset).toBe(
393+
Definitions.AccountLockoutOptions.unlockOnPasswordReset.default
394+
);
395+
});
396+
397+
it('allow login for locked account after password reset', async () => {
398+
await setup({ unlockOnPasswordReset: true });
399+
const config = Config.get(Parse.applicationId);
400+
401+
const user = new Parse.User();
402+
const username = 'exampleUsername';
403+
const password = 'examplePassword';
404+
user.setUsername(username);
405+
user.setPassword(password);
406+
user.setEmail('[email protected]');
407+
await user.signUp();
408+
409+
await expectAsync(Parse.User.logIn(username, 'incorrectPassword')).toBeRejected();
410+
await expectAsync(Parse.User.logIn(username, password)).toBeRejected();
411+
412+
await Parse.User.requestPasswordReset(user.getEmail());
413+
await expectAsync(Parse.User.logIn(username, password)).toBeRejected();
414+
415+
const link = sendPasswordResetEmail.calls.all()[0].args[0].link;
416+
const linkUrl = new URL(link);
417+
const token = linkUrl.searchParams.get('token');
418+
const newPassword = 'newPassword';
419+
await request({
420+
method: 'POST',
421+
url: `${config.publicServerURL}/apps/test/request_password_reset`,
422+
body: `new_password=${newPassword}&token=${token}&username=${username}`,
423+
headers: {
424+
'Content-Type': 'application/x-www-form-urlencoded',
425+
},
426+
followRedirects: false,
427+
});
428+
429+
await expectAsync(Parse.User.logIn(username, newPassword)).toBeResolved();
430+
});
431+
432+
it('reject login for locked account after password reset (default)', async () => {
433+
await setup();
434+
const config = Config.get(Parse.applicationId);
435+
436+
const user = new Parse.User();
437+
const username = 'exampleUsername';
438+
const password = 'examplePassword';
439+
user.setUsername(username);
440+
user.setPassword(password);
441+
user.setEmail('[email protected]');
442+
await user.signUp();
443+
444+
await expectAsync(Parse.User.logIn(username, 'incorrectPassword')).toBeRejected();
445+
await expectAsync(Parse.User.logIn(username, password)).toBeRejected();
446+
447+
await Parse.User.requestPasswordReset(user.getEmail());
448+
await expectAsync(Parse.User.logIn(username, password)).toBeRejected();
449+
450+
const link = sendPasswordResetEmail.calls.all()[0].args[0].link;
451+
const linkUrl = new URL(link);
452+
const token = linkUrl.searchParams.get('token');
453+
const newPassword = 'newPassword';
454+
await request({
455+
method: 'POST',
456+
url: `${config.publicServerURL}/apps/test/request_password_reset`,
457+
body: `new_password=${newPassword}&token=${token}&username=${username}`,
458+
headers: {
459+
'Content-Type': 'application/x-www-form-urlencoded',
460+
},
461+
followRedirects: false,
462+
});
463+
464+
await expectAsync(Parse.User.logIn(username, newPassword)).toBeRejected();
465+
});
466+
});

Diff for: src/AccountLockout.js

+17
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,23 @@ export class AccountLockout {
158158
}
159159
});
160160
}
161+
162+
/**
163+
* Removes the account lockout.
164+
*/
165+
unlockAccount() {
166+
if (!this._config.accountLockout || !this._config.accountLockout.unlockOnPasswordReset) {
167+
return Promise.resolve();
168+
}
169+
return this._config.database.update(
170+
'_User',
171+
{ username: this._user.username },
172+
{
173+
_failed_login_count: { __op: 'Delete' },
174+
_account_lockout_expires_at: { __op: 'Delete' },
175+
}
176+
);
177+
}
161178
}
162179

163180
export default AccountLockout;

Diff for: src/Config.js

+8
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import net from 'net';
99
import {
1010
IdempotencyOptions,
1111
FileUploadOptions,
12+
AccountLockoutOptions,
1213
} from './Options/Definitions';
14+
import { isBoolean } from 'lodash';
1315

1416
function removeTrailingSlash(str) {
1517
if (!str) {
@@ -146,6 +148,12 @@ export class Config {
146148
) {
147149
throw 'Account lockout threshold should be an integer greater than 0 and less than 1000';
148150
}
151+
152+
if (accountLockout.unlockOnPasswordReset === undefined) {
153+
accountLockout.unlockOnPasswordReset = AccountLockoutOptions.unlockOnPasswordReset.default;
154+
} else if (!isBoolean(accountLockout.unlockOnPasswordReset)) {
155+
throw 'Parse Server option accountLockout.unlockOnPasswordReset must be a boolean.';
156+
}
149157
}
150158
}
151159

Diff for: src/Controllers/UserController.js

+9-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import AdaptableController from './AdaptableController';
44
import MailAdapter from '../Adapters/Email/MailAdapter';
55
import rest from '../rest';
66
import Parse from 'parse/node';
7+
import AccountLockout from '../AccountLockout';
78

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

259260
updatePassword(username, token, password) {
260261
return this.checkResetTokenValidity(username, token)
261-
.then(user => updateUserPassword(user.objectId, password, this.config))
262+
.then(user => updateUserPassword(user, password, this.config))
263+
.then(user => {
264+
const accountLockoutPolicy = new AccountLockout(user, this.config);
265+
return accountLockoutPolicy.unlockAccount();
266+
})
262267
.catch(error => {
263268
if (error && error.message) {
264269
// in case of Parse.Error, fail with the error message only
@@ -302,16 +307,16 @@ export class UserController extends AdaptableController {
302307
}
303308

304309
// Mark this private
305-
function updateUserPassword(userId, password, config) {
310+
function updateUserPassword(user, password, config) {
306311
return rest.update(
307312
config,
308313
Auth.master(config),
309314
'_User',
310-
{ objectId: userId },
315+
{ objectId: user.objectId },
311316
{
312317
password: password,
313318
}
314-
);
319+
).then(() => user);
315320
}
316321

317322
function buildEmailLink(destination, username, token, config) {

Diff for: src/Options/Definitions.js

+6
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,12 @@ module.exports.AccountLockoutOptions = {
570570
help: 'number of failed sign-in attempts that will cause a user account to be locked',
571571
action: parsers.numberParser('threshold'),
572572
},
573+
unlockOnPasswordReset: {
574+
env: 'PARSE_SERVER_ACCOUNT_LOCKOUT_UNLOCK_ON_PASSWORD_RESET',
575+
help: 'Is true if the account lock should be removed after a successful password reset.',
576+
action: parsers.booleanParser,
577+
default: false,
578+
},
573579
};
574580
module.exports.PasswordPolicyOptions = {
575581
doNotAllowUsername: {

Diff for: src/Options/docs.js

+1
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
* @interface AccountLockoutOptions
127127
* @property {Number} duration number of minutes that a locked-out account remains locked out before automatically becoming unlocked.
128128
* @property {Number} threshold number of failed sign-in attempts that will cause a user account to be locked
129+
* @property {Boolean} unlockOnPasswordReset Is true if the account lock should be removed after a successful password reset.
129130
*/
130131

131132
/**

Diff for: src/Options/index.js

+3
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,9 @@ export interface AccountLockoutOptions {
301301
duration: ?number;
302302
/* number of failed sign-in attempts that will cause a user account to be locked */
303303
threshold: ?number;
304+
/* Is true if the account lock should be removed after a successful password reset.
305+
:DEFAULT: false */
306+
unlockOnPasswordReset: ?boolean;
304307
}
305308

306309
export interface PasswordPolicyOptions {

0 commit comments

Comments
 (0)