From 4a669a944c24b463f06a3db28df08b3bcd37d8e3 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 5 Jul 2026 01:47:32 +1000 Subject: [PATCH] fix: Account lockout does not reset failed login count after duration expires --- spec/AccountLockoutPolicy.spec.js | 25 ++++++++++++++++ src/AccountLockout.js | 49 +++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/spec/AccountLockoutPolicy.spec.js b/spec/AccountLockoutPolicy.spec.js index 91d30e55fa..62f1f1bec4 100644 --- a/spec/AccountLockoutPolicy.spec.js +++ b/spec/AccountLockoutPolicy.spec.js @@ -342,6 +342,31 @@ describe('Account Lockout Policy: ', () => { }); }); + it('allows a fresh set of attempts after the lockout duration expires', async () => { + await reconfigureServer({ + appName: 'lockout reset', + accountLockout: { duration: 0.05 /* 3s */, threshold: 2 }, + publicServerURL: 'http://localhost:8378/1', + }); + const user = new Parse.User(); + user.setUsername('username_reset'); + user.setPassword('correct password'); + await user.signUp(); + + // Trip the lockout (threshold = 2 failed attempts). + await loginWithWrongCredentialsShouldFail('username_reset', 'wrong password'); + await loginWithWrongCredentialsShouldFail('username_reset', 'wrong password'); + + // Wait out the 3s lockout. + await new Promise(resolve => setTimeout(resolve, 3100)); + + // One post-expiry failure must NOT re-lock (bug: it throws the lockout error here)... + await loginWithWrongCredentialsShouldFail('username_reset', 'wrong password'); + // ...and a correct login within the fresh window must still succeed. + const loggedIn = await Parse.User.logIn('username_reset', 'correct password'); + expect(loggedIn.getUsername()).toBe('username_reset'); + }); + it('should enforce lockout threshold under concurrent failed login attempts', async () => { const threshold = 3; await reconfigureServer({ diff --git a/src/AccountLockout.js b/src/AccountLockout.js index f8371ff436..585b84a69f 100644 --- a/src/AccountLockout.js +++ b/src/AccountLockout.js @@ -120,21 +120,52 @@ export class AccountLockout { } /** - * handle login attempt if the Account Lockout Policy is enabled + * Reset the failed login count once a previous lockout has expired, so the user is + * granted a fresh set of attempts (threshold) instead of being re-locked on the first + * post-expiry failure. */ - handleLoginAttempt(loginSuccessful) { - if (!this._config.accountLockout) { - return Promise.resolve(); - } - return this._notLocked().then(() => { - if (loginSuccessful) { - return this._setFailedLoginCount(0); + _resetFailedLoginCountIfExpired() { + const query = { + username: this._user.username, + _account_lockout_expires_at: { $lt: Parse._encode(new Date()) }, + }; + + const updateFields = { + _failed_login_count: 0, + _account_lockout_expires_at: { __op: 'Delete' }, + }; + + return this._config.database.update('_User', query, updateFields).catch(err => { + if ( + err && + err.code && + err.message && + err.code === Parse.Error.OBJECT_NOT_FOUND && + err.message === 'Object not found.' + ) { + return; // no expired lockout to reset } else { - return this._handleFailedLoginAttempt(); + throw err; // unknown error } }); } + /** + * handle login attempt if the Account Lockout Policy is enabled + */ + async handleLoginAttempt(loginSuccessful) { + if (!this._config.accountLockout) { + return; + } + await this._notLocked(); + if (loginSuccessful) { + await this._setFailedLoginCount(0); + } else { + await this._resetFailedLoginCountIfExpired(); + await this._handleFailedLoginAttempt(); + } + } + /** * Removes the account lockout. */