From d5edc1fd65830e7e45192041b3b7d3ced8f1c810 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 5 Jul 2026 03:06:03 +1000 Subject: [PATCH] fix: MFA SMS token request fails when maxPasswordHistory is set --- spec/AuthenticationAdapters.spec.js | 46 +++++++++++++++++++++++++++++ src/Adapters/Auth/mfa.js | 6 ++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index f975914bd7..1e61261e1d 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -2212,6 +2212,52 @@ describe('OTP SMS auth adatper', () => { ); }); + it('can request an SMS token when maxPasswordHistory is set (#9528)', async () => { + await reconfigureServer({ + auth: { mfa }, + passwordPolicy: { maxPasswordHistory: 5 }, + }); + const user = await Parse.User.signUp('username', 'password'); + const spy = spyOn(mfa, 'sendSMS').and.callThrough(); + await user.save( + { authData: { mfa: { mobile: '+11111111111' } } }, + { sessionToken: user.getSessionToken() } + ); + await user.save( + { authData: { mfa: { mobile, token: code } } }, + { sessionToken: user.getSessionToken() } + ); + spy.calls.reset(); + + const res = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { mfa: { token: 'request' } }, + }), + }).catch(e => e.data); + // Before the fix this was { code: 142, error: 'New password should not be the same as last 5 passwords.' } + expect(res).toEqual({ code: Parse.Error.SCRIPT_FAILED, error: 'Please enter the token' }); + expect(spy).toHaveBeenCalledWith(code, '+11111111111'); + + // Entering the received code still logs in end-to-end + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { mfa: { token: code } }, + }), + }).then(res => res.data); + expect(response.objectId).toEqual(user.id); + expect(response.sessionToken).toBeDefined(); + }); + it('partially enrolled users can still login', async () => { const user = await Parse.User.signUp('username', 'password'); await user.save({ authData: { mfa: { mobile: '+11111111111' } } }); diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js index 9af8601c47..9fa4d3a8e5 100644 --- a/src/Adapters/Auth/mfa.js +++ b/src/Adapters/Auth/mfa.js @@ -136,8 +136,10 @@ class MFAAdapter extends AuthAdapter { const { token: sendToken, expiry } = await this.sendSMS(mobile); auth.mfa.token = sendToken; auth.mfa.expiry = expiry; - req.object.set('authData', auth); - await req.object.save(null, { useMasterKey: true }); + // Persist only authData. Saving req.object here would re-send the plaintext login + // password still dirty on it, re-entering RestWrite as a password change and tripping + // the maxPasswordHistory check (#9528). + await req.config.database.update('_User', { objectId: req.object.id }, { authData: auth }, {}); throw 'Please enter the token'; } if (!saved || token !== saved) {