diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index 82aa8144a0..51f921c979 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -11,6 +11,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h | DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - | | DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - | | DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - | +| DEPPS8 | Allow login with expired authData token | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - | [i_deprecation]: ## "The version and date of the deprecation." [i_removal]: ## "The version and date of the planned removal." diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 195d899819..f576a17a5c 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -3,6 +3,8 @@ const Config = require('../lib/Config'); const defaultColumns = require('../lib/Controllers/SchemaController').defaultColumns; const authenticationLoader = require('../lib/Adapters/Auth'); const path = require('path'); +const Auth = require('../lib/Auth'); + const responses = { gpgames: { playerId: 'userId' }, instagram: { id: 'userId' }, @@ -18,6 +20,14 @@ const responses = { microsoft: { id: 'userId', mail: 'userMail' }, }; +const requestWithExpectedError = async params => { + try { + return await request(params); + } catch (e) { + throw new Error(e.data.error); + } +}; + describe('AuthenticationProviders', function () { [ 'apple', @@ -256,6 +266,49 @@ describe('AuthenticationProviders', function () { .catch(done.fail); }); + it('should support loginWith with session token and with/without mutated authData', async () => { + const fakeAuthProvider = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + const payload = { authData: { id: 'user1', token: 'fakeToken' } }; + const payload2 = { authData: { id: 'user1', token: 'fakeToken2' } }; + await reconfigureServer({ auth: { fakeAuthProvider } }); + const user = await Parse.User.logInWith('fakeAuthProvider', payload); + const user2 = await Parse.User.logInWith('fakeAuthProvider', payload, { + sessionToken: user.getSessionToken(), + }); + const user3 = await Parse.User.logInWith('fakeAuthProvider', payload2, { + sessionToken: user2.getSessionToken(), + }); + expect(user.id).toEqual(user2.id); + expect(user.id).toEqual(user3.id); + }); + + it('should support sync/async validateAppId', async () => { + const syncProvider = { + validateAppId: () => true, + appIds: 'test', + validateAuthData: () => Promise.resolve(), + }; + const asyncProvider = { + appIds: 'test', + validateAppId: () => Promise.resolve(true), + validateAuthData: () => Promise.resolve(), + }; + const payload = { authData: { id: 'user1', token: 'fakeToken' } }; + const syncSpy = spyOn(syncProvider, 'validateAppId'); + const asyncSpy = spyOn(asyncProvider, 'validateAppId'); + + await reconfigureServer({ auth: { asyncProvider, syncProvider } }); + const user = await Parse.User.logInWith('asyncProvider', payload); + const user2 = await Parse.User.logInWith('syncProvider', payload); + expect(user.getSessionToken()).toBeDefined(); + expect(user2.getSessionToken()).toBeDefined(); + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(asyncSpy).toHaveBeenCalledTimes(1); + }); + it('unlink and link with custom provider', async () => { const provider = getMockMyOauthProvider(); Parse.User._registerAuthenticationProvider(provider); @@ -339,10 +392,10 @@ describe('AuthenticationProviders', function () { }); validateAuthenticationHandler(authenticationHandler); - const validator = authenticationHandler.getValidatorForProvider('customAuthentication'); + const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication'); validateValidator(validator); - validator(validAuthData).then( + validator(validAuthData, {}, {}).then( () => { expect(authDataSpy).toHaveBeenCalled(); // AppIds are not provided in the adapter, should not be called @@ -362,12 +415,15 @@ describe('AuthenticationProviders', function () { }); validateAuthenticationHandler(authenticationHandler); - const validator = authenticationHandler.getValidatorForProvider('customAuthentication'); + const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication'); validateValidator(validator); - - validator({ - token: 'my-token', - }).then( + validator( + { + token: 'my-token', + }, + {}, + {} + ).then( () => { done(); }, @@ -387,12 +443,16 @@ describe('AuthenticationProviders', function () { }); validateAuthenticationHandler(authenticationHandler); - const validator = authenticationHandler.getValidatorForProvider('customAuthentication'); + const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication'); validateValidator(validator); - validator({ - token: 'valid-token', - }).then( + validator( + { + token: 'valid-token', + }, + {}, + {} + ).then( () => { done(); }, @@ -2302,3 +2362,1092 @@ describe('facebook limited auth adapter', () => { } }); }); + +describe('Auth Adapter features', () => { + const baseAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + const baseAdapter2 = { + validateAppId: appIds => (appIds[0] === 'test' ? Promise.resolve() : Promise.reject()), + validateAuthData: () => Promise.resolve(), + appIds: ['test'], + options: { anOption: true }, + }; + + const doNotSaveAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve({ doNotSave: true }), + }; + + const additionalAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + policy: 'additional', + }; + + const soloAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + policy: 'solo', + }; + + const challengeAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + challenge: () => Promise.resolve({ token: 'test' }), + options: { + anOption: true, + }, + }; + + const modernAdapter = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.resolve(), + }; + + const modernAdapter2 = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.resolve(), + }; + + const wrongAdapter = { + validateAppId: () => Promise.resolve(), + }; + + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('should ensure no duplicate auth data id after before save', async () => { + await reconfigureServer({ + auth: { baseAdapter }, + cloud: () => { + Parse.Cloud.beforeSave('_User', async request => { + request.object.set('authData', { baseAdapter: { id: 'test' } }); + }); + }, + }); + + const user = new Parse.User(); + await user.save({ authData: { baseAdapter: { id: 'another' } } }); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ baseAdapter: { id: 'test' } }); + + const user2 = new Parse.User(); + await expectAsync( + user2.save({ authData: { baseAdapter: { id: 'another' } } }) + ).toBeRejectedWithError('this auth is already used'); + }); + + it('should ensure no duplicate auth data id after before save in case of more than one result', async () => { + await reconfigureServer({ + auth: { baseAdapter }, + cloud: () => { + Parse.Cloud.beforeSave('_User', async request => { + request.object.set('authData', { baseAdapter: { id: 'test' } }); + }); + }, + }); + + const user = new Parse.User(); + await user.save({ authData: { baseAdapter: { id: 'another' } } }); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ baseAdapter: { id: 'test' } }); + + let i = 0; + const originalFn = Auth.findUsersWithAuthData; + spyOn(Auth, 'findUsersWithAuthData').and.callFake((...params) => { + // First call is triggered during authData validation + if (i === 0) { + i++; + return originalFn(...params); + } + // Second call is triggered after beforeSave. A developer can modify authData during beforeSave. + // To perform a determinist login, the uniqueness of `auth.id` needs to be ensured. + // A developer with a direct access to the database could break something and duplicate authData.id. + // In this case, if 2 matching users are detected for a single authData.id, then the login/register will be canceled. + // Promise.resolve([true, true]) simulates this case with 2 matching users. + return Promise.resolve([true, true]); + }); + const user2 = new Parse.User(); + await expectAsync( + user2.save({ authData: { baseAdapter: { id: 'another' } } }) + ).toBeRejectedWithError('this auth is already used'); + }); + + it('should ensure no duplicate auth data id during authData validation in case of more than one result', async () => { + await reconfigureServer({ + auth: { baseAdapter }, + cloud: () => { + Parse.Cloud.beforeSave('_User', async request => { + request.object.set('authData', { baseAdapter: { id: 'test' } }); + }); + }, + }); + + spyOn(Auth, 'findUsersWithAuthData').and.resolveTo([true, true]); + + const user = new Parse.User(); + await expectAsync( + user.save({ authData: { baseAdapter: { id: 'another' } } }) + ).toBeRejectedWithError('this auth is already used'); + }); + + it('should pass authData, options, request, config to validateAuthData', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ auth: { baseAdapter } }); + const user = new Parse.User(); + const payload = { someData: true }; + + await user.save({ + username: 'test', + password: 'password', + authData: { baseAdapter: payload }, + }); + + expect(user.getSessionToken()).toBeDefined(); + const firstCall = baseAdapter.validateAuthData.calls.argsFor(0); + expect(firstCall[0]).toEqual(payload); + expect(firstCall[1]).toEqual(baseAdapter); + expect(firstCall[2].object).toBeDefined(); + expect(firstCall[2].original).toBeUndefined(); + expect(firstCall[2].user).toBeUndefined(); + expect(firstCall[2].isChallenge).toBeUndefined(); + expect(firstCall[3] instanceof Config).toBeTruthy(); + + await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'test', + password: 'password', + authData: { baseAdapter: payload }, + }), + }); + const secondCall = baseAdapter.validateAuthData.calls.argsFor(1); + expect(secondCall[0]).toEqual(payload); + expect(secondCall[1]).toEqual(baseAdapter); + expect(secondCall[2].original).toBeDefined(); + expect(secondCall[2].original instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].original.id).toEqual(user.id); + expect(secondCall[2].object).toBeDefined(); + expect(secondCall[2].object instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].object.id).toEqual(user.id); + expect(secondCall[2].isChallenge).toBeUndefined(); + expect(secondCall[2].user).toBeUndefined(); + expect(secondCall[3] instanceof Config).toBeTruthy(); + }); + + it('should trigger correctly validateSetUp', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + spyOn(modernAdapter2, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter2, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter2, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter, modernAdapter2 } }); + const user = new Parse.User(); + + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + const call = modernAdapter.validateSetUp.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].isChallenge).toBeUndefined(); + expect(call[2].master).toBeDefined(); + expect(call[2].object instanceof Parse.User).toBeTruthy(); + expect(call[2].user).toBeUndefined(); + expect(call[2].original).toBeUndefined(); + expect(call[3] instanceof Config).toBeTruthy(); + expect(user.getSessionToken()).toBeDefined(); + + await user.save( + { authData: { modernAdapter2: { id: 'modernAdapter2' } } }, + { sessionToken: user.getSessionToken() } + ); + + expect(modernAdapter2.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter2.validateLogin).toHaveBeenCalledTimes(0); + expect(modernAdapter2.validateSetUp).toHaveBeenCalledTimes(1); + const call2 = modernAdapter2.validateSetUp.calls.argsFor(0); + expect(call2[0]).toEqual({ id: 'modernAdapter2' }); + expect(call2[1]).toEqual(modernAdapter2); + expect(call2[2].isChallenge).toBeUndefined(); + expect(call2[2].master).toBeDefined(); + expect(call2[2].object instanceof Parse.User).toBeTruthy(); + expect(call2[2].original instanceof Parse.User).toBeTruthy(); + expect(call2[2].user instanceof Parse.User).toBeTruthy(); + expect(call2[2].original.id).toEqual(call2[2].object.id); + expect(call2[2].user.id).toEqual(call2[2].object.id); + expect(call2[2].object.id).toEqual(user.id); + expect(call2[3] instanceof Config).toBeTruthy(); + + const user2 = new Parse.User(); + user2.id = user.id; + await user2.fetch({ useMasterKey: true }); + expect(user2.get('authData')).toEqual({ + modernAdapter: { id: 'modernAdapter' }, + modernAdapter2: { id: 'modernAdapter2' }, + }); + }); + + it('should trigger correctly validateLogin', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter }, allowExpiredAuthDataToken: false }); + const user = new Parse.User(); + + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + const user2 = new Parse.User(); + await user2.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(1); + const call = modernAdapter.validateLogin.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].object instanceof Parse.User).toBeTruthy(); + expect(call[2].original instanceof Parse.User).toBeTruthy(); + expect(call[2].isChallenge).toBeUndefined(); + expect(call[2].master).toBeDefined(); + expect(call[2].user).toBeUndefined(); + expect(call[2].original.id).toEqual(user2.id); + expect(call[2].object.id).toEqual(user2.id); + expect(call[2].object.id).toEqual(user.id); + expect(call[3] instanceof Config).toBeTruthy(); + expect(user2.getSessionToken()).toBeDefined(); + }); + + it('should trigger correctly validateUpdate', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter } }); + const user = new Parse.User(); + + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + + // Save same data + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter' } } }, + { sessionToken: user.getSessionToken() } + ); + + // Save same data with master key + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter' } } }, + { useMasterKey: true } + ); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + + // Change authData + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter2' } } }, + { sessionToken: user.getSessionToken() } + ); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + const call = modernAdapter.validateUpdate.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter2' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].isChallenge).toBeUndefined(); + expect(call[2].master).toBeDefined(); + expect(call[2].object instanceof Parse.User).toBeTruthy(); + expect(call[2].user instanceof Parse.User).toBeTruthy(); + expect(call[2].original instanceof Parse.User).toBeTruthy(); + expect(call[2].object.id).toEqual(user.id); + expect(call[2].original.id).toEqual(user.id); + expect(call[2].user.id).toEqual(user.id); + expect(call[3] instanceof Config).toBeTruthy(); + expect(user.getSessionToken()).toBeDefined(); + }); + + it('should throw if no triggers found', async () => { + await reconfigureServer({ auth: { wrongAdapter } }); + const user = new Parse.User(); + await expectAsync( + user.save({ authData: { wrongAdapter: { id: 'wrongAdapter' } } }) + ).toBeRejectedWithError( + 'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate' + ); + }); + + it('should throw if policy does not match one of default/solo/additional', async () => { + const adapterWithBadPolicy = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + policy: 'bad', + }; + await reconfigureServer({ auth: { adapterWithBadPolicy } }); + const user = new Parse.User(); + await expectAsync( + user.save({ authData: { adapterWithBadPolicy: { id: 'adapterWithBadPolicy' } } }) + ).toBeRejectedWithError( + 'AuthAdapter policy is not configured correctly. The value must be either "solo", "additional", "default" or undefined (will be handled as "default")' + ); + }); + + it('should throw if no triggers found', async () => { + await reconfigureServer({ auth: { wrongAdapter } }); + const user = new Parse.User(); + await expectAsync( + user.save({ authData: { wrongAdapter: { id: 'wrongAdapter' } } }) + ).toBeRejectedWithError( + 'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate' + ); + }); + + it('should not update authData if provider return doNotSave', async () => { + spyOn(doNotSaveAdapter, 'validateAuthData').and.resolveTo({ doNotSave: true }); + await reconfigureServer({ + auth: { doNotSaveAdapter, baseAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { baseAdapter: { id: 'baseAdapter' }, doNotSaveAdapter: { token: true } }, + }); + + await user.fetch({ useMasterKey: true }); + + expect(user.get('authData')).toEqual({ baseAdapter: { id: 'baseAdapter' } }); + }); + + it('should loginWith user with auth Adapter with do not save option, mutated authData and no additional auth adapter', async () => { + const spy = spyOn(doNotSaveAdapter, 'validateAuthData').and.resolveTo({ doNotSave: false }); + await reconfigureServer({ + auth: { doNotSaveAdapter, baseAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { doNotSaveAdapter: { id: 'doNotSaveAdapter' } }, + }); + + await user.fetch({ useMasterKey: true }); + + expect(user.get('authData')).toEqual({ doNotSaveAdapter: { id: 'doNotSaveAdapter' } }); + + spy.and.resolveTo({ doNotSave: true }); + + const user2 = await Parse.User.logInWith('doNotSaveAdapter', { + authData: { id: 'doNotSaveAdapter', example: 'example' }, + }); + expect(user2.getSessionToken()).toBeDefined(); + expect(user2.id).toEqual(user.id); + }); + + it('should perform authData validation only when its required', async () => { + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({}); + spyOn(baseAdapter2, 'validateAppId').and.resolveTo({}); + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ + auth: { baseAdapter2, baseAdapter }, + allowExpiredAuthDataToken: false, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { token: true }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(1); + expect(baseAdapter2.validateAppId).toHaveBeenCalledTimes(1); + + const user2 = new Parse.User(); + await user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(1); + + const user3 = new Parse.User(); + await user3.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { token: true }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(2); + }); + + it('should require additional provider if configured', async () => { + await reconfigureServer({ + auth: { baseAdapter, additionalAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + const user2 = new Parse.User(); + await expectAsync( + user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + }, + }) + ).toBeRejectedWithError('Missing additional authData additionalAdapter'); + expect(user2.getSessionToken()).toBeUndefined(); + + await user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + expect(user2.getSessionToken()).toBeDefined(); + }); + + it('should skip additional provider if used provider is solo', async () => { + await reconfigureServer({ + auth: { soloAdapter, additionalAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + soloAdapter: { id: 'soloAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + const user2 = new Parse.User(); + await user2.save({ + authData: { + soloAdapter: { id: 'soloAdapter' }, + }, + }); + expect(user2.getSessionToken()).toBeDefined(); + }); + + it('should return authData response and save some info on non username login', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + expect(user.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const user2 = new Parse.User(); + user2.id = user.id; + await user2.save( + { + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(user2.get('authDataResponse')).toEqual({ baseAdapter2: { someData2: true } }); + + const user3 = new Parse.User(); + await user3.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + // On logIn all authData are revalidated + expect(user3.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const userViaMasterKey = new Parse.User(); + userViaMasterKey.id = user2.id; + await userViaMasterKey.fetch({ useMasterKey: true }); + expect(userViaMasterKey.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { otherData: true }, + }); + }); + + it('should return authData response and save some info on username login', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + + const user = new Parse.User(); + + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + expect(user.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }), + }); + const result = res.data; + expect(result.authDataResponse).toEqual({ + baseAdapter2: { someData2: true }, + baseAdapter: { someData: true }, + }); + + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { otherData: true }, + }); + }); + + describe('should allow update of authData', () => { + beforeEach(async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + }); + + it('should not re validate the baseAdapter when user is already logged in and authData not changed', async () => { + const user = new Parse.User(); + + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + + expect(user.id).toBeDefined(); + expect(user.getSessionToken()).toBeDefined(); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + }); + + it('should not re-validate the baseAdapter when master key is used and authData has not changed', async () => { + const user = new Parse.User(); + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }, + { useMasterKey: true } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + }); + + it('should allow user to change authData', async () => { + const user = new Parse.User(); + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter2' }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(2); + }); + + it('should allow master key to change authData', async () => { + const user = new Parse.User(); + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter3' }, + }, + }, + { useMasterKey: true } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(2); + + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter3' }, + baseAdapter2: { otherData: true }, + }); + }); + }); + + it('should pass user to auth adapter on update by matching session', async () => { + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ auth: { baseAdapter2 } }); + + const user = new Parse.User(); + + const payload = { someData: true }; + + await user.save({ + username: 'test', + password: 'password', + }); + + expect(user.getSessionToken()).toBeDefined(); + + await user.save( + { authData: { baseAdapter2: payload } }, + { sessionToken: user.getSessionToken() } + ); + + const firstCall = baseAdapter2.validateAuthData.calls.argsFor(0); + expect(firstCall[0]).toEqual(payload); + expect(firstCall[1]).toEqual(baseAdapter2); + expect(firstCall[2].isChallenge).toBeUndefined(); + expect(firstCall[2].master).toBeDefined(); + expect(firstCall[2].object instanceof Parse.User).toBeTruthy(); + expect(firstCall[2].user instanceof Parse.User).toBeTruthy(); + expect(firstCall[2].original instanceof Parse.User).toBeTruthy(); + expect(firstCall[2].object.id).toEqual(user.id); + expect(firstCall[2].original.id).toEqual(user.id); + expect(firstCall[2].user.id).toEqual(user.id); + expect(firstCall[3] instanceof Config).toBeTruthy(); + + await user.save({ authData: { baseAdapter2: payload } }, { useMasterKey: true }); + + const secondCall = baseAdapter2.validateAuthData.calls.argsFor(1); + expect(secondCall[0]).toEqual(payload); + expect(secondCall[1]).toEqual(baseAdapter2); + expect(secondCall[2].isChallenge).toBeUndefined(); + expect(secondCall[2].master).toEqual(true); + expect(secondCall[2].user).toBeUndefined(); + expect(secondCall[2].object instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].original instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].object.id).toEqual(user.id); + expect(secondCall[2].original.id).toEqual(user.id); + expect(secondCall[3] instanceof Config).toBeTruthy(); + }); + + it('should return challenge with no logged user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter }, + }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: {}, + }) + ).toBeRejectedWithError('Nothing to challenge.'); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: { challengeData: true }, + }) + ).toBeRejectedWithError('challengeData should be an object.'); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: { challengeData: { data: true }, authData: true }, + }) + ).toBeRejectedWithError('authData should be an object.'); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }); + + expect(res.data).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toBeUndefined(); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].master).toBeDefined(); + expect(challengeCall[3].headers).toBeDefined(); + expect(challengeCall[3].object).toBeUndefined(); + expect(challengeCall[3].original).toBeUndefined(); + expect(challengeCall[3].user).toBeUndefined(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall[4] instanceof Config).toBeTruthy(); + }); + + it('should return empty challenge data response if challenged provider does not exists', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter }, + }); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + nonExistingProvider: { someData: true }, + }, + }), + }); + + expect(res.data).toEqual({ challengeData: {} }); + }); + it('should return challenge with username created user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter }, + }); + + const user = new Parse.User(); + await user.save({ username: 'username', password: 'password' }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }) + ).toBeRejectedWithError('You provided username or email, you need to also provide password.'); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { data: true }, + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }) + ).toBeRejectedWithError( + 'You cannot provide username/email and authData, only use one identification method.' + ); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + password: 'password', + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }); + + expect(res.data).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toEqual(undefined); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].master).toBeDefined(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall[3].user).toBeUndefined(); + expect(challengeCall[3].object instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].original instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].object.id).toEqual(user.id); + expect(challengeCall[3].original.id).toEqual(user.id); + expect(challengeCall[4] instanceof Config).toBeTruthy(); + }); + + it('should return challenge with authData created user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + spyOn(challengeAdapter, 'validateAuthData').and.callThrough(); + + await reconfigureServer({ + auth: { challengeAdapter, soloAdapter }, + }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('User not found.'); + + const user = new Parse.User(); + await user.save({ authData: { challengeAdapter: { id: 'challengeAdapter' } } }); + + const user2 = new Parse.User(); + await user2.save({ authData: { soloAdapter: { id: 'soloAdapter' } } }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + soloAdapter: { id: 'soloAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('You cannot provide more than one authData provider with an id.'); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }); + + expect(res.data).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + + const validateCall = challengeAdapter.validateAuthData.calls.argsFor(1); + expect(validateCall[2].isChallenge).toBeTruthy(); + + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toEqual({ id: 'challengeAdapter' }); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].master).toBeDefined(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall[3].object instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].original instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].object.id).toEqual(user.id); + expect(challengeCall[3].original.id).toEqual(user.id); + expect(challengeCall[4] instanceof Config).toBeTruthy(); + }); + + it('should validate provided authData and prevent guess id attack', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter, soloAdapter }, + }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('User not found.'); + + const user = new Parse.User(); + await user.save({ authData: { challengeAdapter: { id: 'challengeAdapter' } } }); + + spyOn(challengeAdapter, 'validateAuthData').and.rejectWith({}); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('User not found.'); + + const validateCall = challengeAdapter.validateAuthData.calls.argsFor(0); + expect(challengeAdapter.validateAuthData).toHaveBeenCalledTimes(1); + expect(validateCall[0]).toEqual({ id: 'challengeAdapter' }); + expect(validateCall[1]).toEqual(challengeAdapter); + expect(validateCall[2].isChallenge).toBeTruthy(); + expect(validateCall[2].master).toBeDefined(); + expect(validateCall[2].object instanceof Parse.User).toBeTruthy(); + expect(validateCall[2].original instanceof Parse.User).toBeTruthy(); + expect(validateCall[2].object.id).toEqual(user.id); + expect(validateCall[2].original.id).toEqual(user.id); + expect(validateCall[3] instanceof Config).toBeTruthy(); + }); +}); diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 205de6263c..ffb2f6e30b 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -1,5 +1,6 @@ const http = require('http'); const express = require('express'); +const Config = require('../lib/Config'); const req = require('../lib/request'); const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); const FormData = require('form-data'); @@ -942,8 +943,7 @@ describe('ParseGraphQLServer', () => { ).data['__type'].inputFields .map(field => field.name) .sort(); - - expect(inputFields).toEqual(['clientMutationId', 'password', 'username']); + expect(inputFields).toEqual(['authData', 'clientMutationId', 'password', 'username']); }); it('should have clientMutationId in log in mutation payload', async () => { @@ -7027,7 +7027,61 @@ describe('ParseGraphQLServer', () => { }); describe('Users Mutations', () => { + const challengeAdapter = { + validateAuthData: () => Promise.resolve({ response: { someData: true } }), + validateAppId: () => Promise.resolve(), + challenge: () => Promise.resolve({ someData: true }), + options: { anOption: true }, + }; + + it('should create user and return authData response', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); + const clientMutationId = uuidv4(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation createUser($input: CreateUserInput!) { + createUser(input: $input) { + clientMutationId + user { + id + authDataResponse + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: { + authData: { + challengeAdapter: { + id: 'challengeAdapter', + }, + }, + }, + }, + }, + }); + + expect(result.data.createUser.clientMutationId).toEqual(clientMutationId); + expect(result.data.createUser.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); + }); + it('should sign user up', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); const clientMutationId = uuidv4(); const userSchema = new Parse.Schema('_User'); userSchema.addString('someField'); @@ -7044,6 +7098,7 @@ describe('ParseGraphQLServer', () => { sessionToken user { someField + authDataResponse aPointer { id username @@ -7059,6 +7114,11 @@ describe('ParseGraphQLServer', () => { fields: { username: 'user1', password: 'user1', + authData: { + challengeAdapter: { + id: 'challengeAdapter', + }, + }, aPointer: { createAndLink: { username: 'user2', @@ -7078,6 +7138,9 @@ describe('ParseGraphQLServer', () => { expect(result.data.signUp.viewer.user.aPointer.id).toBeDefined(); expect(result.data.signUp.viewer.user.aPointer.username).toEqual('user2'); expect(typeof result.data.signUp.viewer.sessionToken).toBe('string'); + expect(result.data.signUp.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); }); it('should login with user', async () => { @@ -7086,6 +7149,7 @@ describe('ParseGraphQLServer', () => { parseServer = await global.reconfigureServer({ publicServerURL: 'http://localhost:13377/parse', auth: { + challengeAdapter, myAuth: { module: global.mockCustomAuthenticator('parse', 'graphql'), }, @@ -7105,6 +7169,7 @@ describe('ParseGraphQLServer', () => { sessionToken user { someField + authDataResponse aPointer { id username @@ -7118,6 +7183,7 @@ describe('ParseGraphQLServer', () => { input: { clientMutationId, authData: { + challengeAdapter: { id: 'challengeAdapter' }, myAuth: { id: 'parse', password: 'graphql', @@ -7143,9 +7209,93 @@ describe('ParseGraphQLServer', () => { expect(typeof result.data.logInWith.viewer.sessionToken).toBe('string'); expect(result.data.logInWith.viewer.user.aPointer.id).toBeDefined(); expect(result.data.logInWith.viewer.user.aPointer.username).toEqual('user2'); + expect(result.data.logInWith.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); + }); + + it('should handle challenge', async () => { + const clientMutationId = uuidv4(); + + spyOn(challengeAdapter, 'challenge').and.callThrough(); + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); + + const user = new Parse.User(); + await user.save({ username: 'username', password: 'password' }); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation Challenge($input: ChallengeInput!) { + challenge(input: $input) { + clientMutationId + challengeData + } + } + `, + variables: { + input: { + clientMutationId, + username: 'username', + password: 'password', + challengeData: { + challengeAdapter: { someChallengeData: true }, + }, + }, + }, + }); + + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someChallengeData: true }); + expect(challengeCall[1]).toEqual(undefined); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].object instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].original instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall[3].object.id).toEqual(user.id); + expect(challengeCall[3].original.id).toEqual(user.id); + expect(challengeCall[4] instanceof Config).toBeTruthy(); + expect(result.data.challenge.clientMutationId).toEqual(clientMutationId); + expect(result.data.challenge.challengeData).toEqual({ + challengeAdapter: { someData: true }, + }); + + await expectAsync( + apolloClient.mutate({ + mutation: gql` + mutation Challenge($input: ChallengeInput!) { + challenge(input: $input) { + clientMutationId + challengeData + } + } + `, + variables: { + input: { + clientMutationId, + username: 'username', + password: 'wrongPassword', + challengeData: { + challengeAdapter: { someChallengeData: true }, + }, + }, + }, + }) + ).toBeRejected(); }); it('should log the user in', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); const clientMutationId = uuidv4(); const user = new Parse.User(); user.setUsername('user1'); @@ -7162,6 +7312,7 @@ describe('ParseGraphQLServer', () => { viewer { sessionToken user { + authDataResponse someField } } @@ -7173,6 +7324,7 @@ describe('ParseGraphQLServer', () => { clientMutationId, username: 'user1', password: 'user1', + authData: { challengeAdapter: { token: true } }, }, }, }); @@ -7181,6 +7333,9 @@ describe('ParseGraphQLServer', () => { expect(result.data.logIn.viewer.sessionToken).toBeDefined(); expect(result.data.logIn.viewer.user.someField).toEqual('someValue'); expect(typeof result.data.logIn.viewer.sessionToken).toBe('string'); + expect(result.data.logIn.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); }); it('should log the user out', async () => { diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 92301316e4..1dcb457859 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -13,6 +13,63 @@ const passwordCrypto = require('../lib/password'); const Config = require('../lib/Config'); const cryptoUtils = require('../lib/cryptoUtils'); +describe('allowExpiredAuthDataToken option', () => { + it('should accept true value', async () => { + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); + await reconfigureServer({ allowExpiredAuthDataToken: true }); + expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(true); + expect( + logSpy.calls + .all() + .filter( + log => + log.args[0] === + `DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.` + ).length + ).toEqual(0); + }); + + it('should accept false value', async () => { + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); + await reconfigureServer({ allowExpiredAuthDataToken: false }); + expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(false); + expect( + logSpy.calls + .all() + .filter( + log => + log.args[0] === + `DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.` + ).length + ).toEqual(0); + }); + + it('should default true', async () => { + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); + await reconfigureServer({}); + expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(true); + expect( + logSpy.calls + .all() + .filter( + log => + log.args[0] === + `DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.` + ).length + ).toEqual(1); + }); + + it('should enforce boolean values', async () => { + const options = [[], 'a', '', 0, 1, {}, 'true', 'false']; + for (const option of options) { + await expectAsync(reconfigureServer({ allowExpiredAuthDataToken: option })).toBeRejected(); + } + }); +}); + describe('Parse.User testing', () => { it('user sign up class method', async done => { const user = await Parse.User.signUp('asdf', 'zxcv'); @@ -1129,7 +1186,7 @@ describe('Parse.User testing', () => { this.synchronizedExpiration = authData.expiration_date; return true; }, - getAuthType: function () { + getAuthType() { return 'facebook'; }, deauthenticate: function () { @@ -1158,7 +1215,7 @@ describe('Parse.User testing', () => { synchronizedAuthToken: null, synchronizedExpiration: null, - authenticate: function (options) { + authenticate(options) { if (this.shouldError) { options.error(this, 'An error occurred'); } else if (this.shouldCancel) { @@ -1167,7 +1224,7 @@ describe('Parse.User testing', () => { options.success(this, this.authData); } }, - restoreAuthentication: function (authData) { + restoreAuthentication(authData) { if (!authData) { this.synchronizedUserId = null; this.synchronizedAuthToken = null; @@ -1179,10 +1236,10 @@ describe('Parse.User testing', () => { this.synchronizedExpiration = authData.expiration_date; return true; }, - getAuthType: function () { + getAuthType() { return 'myoauth'; }, - deauthenticate: function () { + deauthenticate() { this.loggedOut = true; this.restoreAuthentication(null); }, @@ -1792,7 +1849,7 @@ describe('Parse.User testing', () => { }); }); - it('should allow login with old authData token', done => { + it('should allow login with expired authData token by default', async () => { const provider = { authData: { id: '12345', @@ -1813,22 +1870,42 @@ describe('Parse.User testing', () => { }; defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token'); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith('shortLivedAuth', {}) - .then(() => { - // Simulate a remotely expired token (like a short lived one) - // In this case, we want success as it was valid once. - // If the client needs an updated one, do lock the user out - defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); - return Parse.User._logInWith('shortLivedAuth', {}); - }) - .then( - () => { - done(); - }, - err => { - done.fail(err); - } - ); + await Parse.User._logInWith('shortLivedAuth', {}); + // Simulate a remotely expired token (like a short lived one) + // In this case, we want success as it was valid once. + // If the client needs an updated token, do lock the user out + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); + await Parse.User._logInWith('shortLivedAuth', {}); + }); + + it('should not allow login with expired authData token when allowExpiredAuthDataToken is set to false', async () => { + await reconfigureServer({ allowExpiredAuthDataToken: false }); + const provider = { + authData: { + id: '12345', + access_token: 'token', + }, + restoreAuthentication() { + return true; + }, + deauthenticate() { + provider.authData = {}; + }, + authenticate(options) { + options.success(this, provider.authData); + }, + getAuthType() { + return 'shortLivedAuth'; + }, + }; + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token'); + Parse.User._registerAuthenticationProvider(provider); + await Parse.User._logInWith('shortLivedAuth', {}); + // Simulate a remotely expired token (like a short lived one) + // In this case, we want success as it was valid once. + // If the client needs an updated token, do lock the user out + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); + expectAsync(Parse.User._logInWith('shortLivedAuth', {})).toBeRejected(); }); it('should allow PUT request with stale auth Data', done => { @@ -2260,37 +2337,14 @@ describe('Parse.User testing', () => { }); describe('anonymous users', () => { - beforeEach(() => { - const insensitiveCollisions = [ - 'abcdefghijklmnop', - 'Abcdefghijklmnop', - 'ABcdefghijklmnop', - 'ABCdefghijklmnop', - 'ABCDefghijklmnop', - 'ABCDEfghijklmnop', - 'ABCDEFghijklmnop', - 'ABCDEFGhijklmnop', - 'ABCDEFGHijklmnop', - 'ABCDEFGHIjklmnop', - 'ABCDEFGHIJklmnop', - 'ABCDEFGHIJKlmnop', - 'ABCDEFGHIJKLmnop', - 'ABCDEFGHIJKLMnop', - 'ABCDEFGHIJKLMnop', - 'ABCDEFGHIJKLMNop', - 'ABCDEFGHIJKLMNOp', - 'ABCDEFGHIJKLMNOP', - ]; - - // need a bunch of spare random strings per api request - spyOn(cryptoUtils, 'randomString').and.returnValues(...insensitiveCollisions); - }); - it('should not fail on case insensitive matches', async () => { - const user1 = await Parse.AnonymousUtils.logIn(); + spyOn(cryptoUtils, 'randomString').and.returnValue('abcdefghijklmnop'); + const logIn = id => Parse.User.logInWith('anonymous', { authData: { id } }); + const user1 = await logIn('test1'); const username1 = user1.get('username'); - const user2 = await Parse.AnonymousUtils.logIn(); + cryptoUtils.randomString.and.returnValue('ABCDEFGHIJKLMNOp'); + const user2 = await logIn('test2'); const username2 = user2.get('username'); expect(username1).not.toBeUndefined(); diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 9af6d5e449..8fc687e175 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -1,20 +1,102 @@ /*eslint no-unused-vars: "off"*/ + +/** + * @interface ParseAuthResponse + * @property {Boolean} [doNotSave] If true, Parse Server will do not save provided authData. + * @property {Object} [response] If set, Parse Server will send the provided response to the client under authDataResponse + * @property {Object} [save] If set, Parse Server will save the object provided into this key, instead of client provided authData + */ + +/** + * AuthPolicy + * default: can be combined with ONE additional auth provider if additional configured on user + * additional: could be only used with a default policy auth provider + * solo: Will ignore ALL additional providers if additional configured on user + * @typedef {"default" | "additional" | "solo"} AuthPolicy + */ + export class AuthAdapter { - /* - @param appIds: the specified app ids in the configuration - @param authData: the client provided authData - @param options: additional options - @returns a promise that resolves if the applicationId is valid + constructor() { + /** + * Usage policy + * @type {AuthPolicy} + */ + this.policy = 'default'; + } + /** + * @param appIds The specified app IDs in the configuration + * @param {Object} authData The client provided authData + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config + * @returns {(Promise|void|undefined)} resolves or returns if the applicationId is valid + */ + validateAppId(appIds, authData, options, request) { + return Promise.resolve({}); + } + + /** + * Legacy usage, if provided it will be triggered when authData related to this provider is touched (signup/update/login) + * otherwise you should implement validateSetup, validateLogin and validateUpdate + * @param {Object} authData The client provided authData + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config Parse Server config object + * @returns {Promise} + */ + validateAuthData(authData, options, request, config) { + return Promise.resolve({}); + } + + /** + * Triggered when user provide for the first time this auth provider + * could be a register or the user adding a new auth service + * @param {Object} authData The client provided authData + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config Parse Server config object + * @returns {Promise} + */ + validateSetUp(authData, options, req, user) { + return Promise.resolve({}); + } + + /** + * Triggered when user provide authData related to this provider + * The user is not logged in and has already set this provider before + * @param {Object} authData The client provided authData + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config + * @returns {Promise} + */ + validateLogin(authData, options, req, user) { + return Promise.resolve({}); + } + + /** + * Triggered when user provide authData related to this provider + * the user is logged in and has already set this provider before + * @param {Object} authData The client provided authData + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config + * @returns {Promise} */ - validateAppId(appIds, authData, options) { + validateUpdate(authData, options, req, user) { return Promise.resolve({}); } - /* - @param authData: the client provided authData - @param options: additional options + /** + * Triggered in pre authentication process if needed (like webauthn, SMS OTP) + * @param {Object} challengeData Data provided by the client + * @param {(Object|undefined)} authData Auth data provided by the client, can be used for validation + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config Parse Server config object + * @returns {Promise} A promise that resolves, resolved value will be added to challenge response under challenge key */ - validateAuthData(authData, options) { + challenge(challengeData, authData, options, req, user) { return Promise.resolve({}); } } diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 00637d1131..0d676f4f2b 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -1,4 +1,5 @@ import loadAdapter from '../AdapterLoader'; +import Parse from 'parse/node'; const apple = require('./apple'); const gcenter = require('./gcenter'); @@ -61,19 +62,72 @@ const providers = { ldap, }; -function authDataValidator(adapter, appIds, options) { - return function (authData) { - return adapter.validateAuthData(authData, options).then(() => { - if (appIds) { - return adapter.validateAppId(appIds, authData, options); +// Indexed auth policies +const authAdapterPolicies = { + default: true, + solo: true, + additional: true, +}; + +function authDataValidator(provider, adapter, appIds, options) { + return async function (authData, req, user, requestObject) { + if (appIds && typeof adapter.validateAppId === 'function') { + await Promise.resolve( + adapter.validateAppId(appIds, authData, options, requestObject, req.config) + ); + } + if (adapter.policy && !authAdapterPolicies[adapter.policy]) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'AuthAdapter policy is not configured correctly. The value must be either "solo", "additional", "default" or undefined (will be handled as "default")' + ); + } + if (typeof adapter.validateAuthData === 'function') { + return adapter.validateAuthData(authData, options, requestObject, req.config); + } else if ( + typeof adapter.validateSetUp === 'function' && + typeof adapter.validateLogin === 'function' && + typeof adapter.validateUpdate === 'function' + ) { + // When masterKey is detected, we should trigger a logged in user + const isLoggedIn = + (req.auth.user && user && req.auth.user.id === user.id) || (user && req.auth.isMaster); + let hasAuthDataConfigured = false; + + if (user && user.get('authData') && user.get('authData')[provider]) { + hasAuthDataConfigured = true; } - return Promise.resolve(); - }); + + if (isLoggedIn) { + // User is updating their authData + if (hasAuthDataConfigured) { + return adapter.validateUpdate(authData, options, requestObject, req.config); + } + // Set up if the user does not have the provider configured + return adapter.validateSetUp(authData, options, requestObject, req.config); + } + + // Not logged in and authData is configured on the user + if (hasAuthDataConfigured) { + return adapter.validateLogin(authData, options, requestObject, req.config); + } + + // User not logged in and the provider is not set up, for example when a new user + // signs up or an existing user uses a new auth provider + return adapter.validateSetUp(authData, options, requestObject, req.config); + } + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate' + ); }; } function loadAuthAdapter(provider, authOptions) { + // providers are auth providers implemented by default let defaultAdapter = providers[provider]; + // authOptions can contain complete custom auth adapters or + // a default auth adapter like Facebook const providerOptions = authOptions[provider]; if ( providerOptions && @@ -83,6 +137,7 @@ function loadAuthAdapter(provider, authOptions) { defaultAdapter = oauth2; } + // Default provider not found and a custom auth provider was not provided if (!defaultAdapter && !providerOptions) { return; } @@ -94,7 +149,15 @@ function loadAuthAdapter(provider, authOptions) { if (providerOptions) { const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); if (optionalAdapter) { - ['validateAuthData', 'validateAppId'].forEach(key => { + [ + 'validateAuthData', + 'validateAppId', + 'validateSetUp', + 'validateLogin', + 'validateUpdate', + 'challenge', + 'policy', + ].forEach(key => { if (optionalAdapter[key]) { adapter[key] = optionalAdapter[key]; } @@ -102,14 +165,6 @@ function loadAuthAdapter(provider, authOptions) { } } - // TODO: create a new module from validateAdapter() in - // src/Controllers/AdaptableController.js so we can use it here for adapter - // validation based on the src/Adapters/Auth/AuthAdapter.js expected class - // signature. - if (!adapter.validateAuthData || !adapter.validateAppId) { - return; - } - return { adapter, appIds, providerOptions }; } @@ -121,12 +176,12 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) { // To handle the test cases on configuration const getValidatorForProvider = function (provider) { if (provider === 'anonymous' && !_enableAnonymousUsers) { - return; + return { validator: undefined }; } - - const { adapter, appIds, providerOptions } = loadAuthAdapter(provider, authOptions); - - return authDataValidator(adapter, appIds, providerOptions); + const authAdapter = loadAuthAdapter(provider, authOptions); + if (!authAdapter) return; + const { adapter, appIds, providerOptions } = authAdapter; + return { validator: authDataValidator(provider, adapter, appIds, providerOptions), adapter }; }; return Object.freeze({ diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index eb668868c6..8e9858254d 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -289,7 +289,6 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus continue; } } - const authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/); if (authDataMatch) { // TODO: Handle querying by _auth_data_provider, authData is stored in authData field @@ -1322,12 +1321,17 @@ export class PostgresStorageAdapter implements StorageAdapter { return; } var authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/); + const authDataAlreadyExists = !!object.authData; if (authDataMatch) { var provider = authDataMatch[1]; object['authData'] = object['authData'] || {}; object['authData'][provider] = object[fieldName]; delete object[fieldName]; fieldName = 'authData'; + // Avoid adding authData multiple times to the query + if (authDataAlreadyExists) { + return; + } } columnsArray.push(fieldName); @@ -1807,7 +1811,6 @@ export class PostgresStorageAdapter implements StorageAdapter { caseInsensitive, }); values.push(...where.values); - const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; const limitPattern = hasLimit ? `LIMIT $${values.length + 1}` : ''; if (hasLimit) { diff --git a/src/Auth.js b/src/Auth.js index ce5c71c860..b8b20ebcdf 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -1,5 +1,15 @@ -const RestQuery = require('./RestQuery'); const Parse = require('parse/node'); +import { isDeepStrictEqual } from 'util'; +import { getRequestObject } from './triggers'; +import Deprecator from './Deprecator/Deprecator'; + +const reducePromise = async (arr, fn, acc, index = 0) => { + if (arr[index]) { + const newAcc = await Promise.resolve(fn(acc, arr[index])); + return reducePromise(arr, fn, newAcc, index + 1); + } + return acc; +}; // An Auth object tells you who is requesting something and whether // the master key was used. @@ -83,7 +93,7 @@ const getAuthForSessionToken = async function ({ limit: 1, include: 'user', }; - + const RestQuery = require('./RestQuery'); const query = new RestQuery(config, master(config), '_Session', { sessionToken }, restOptions); results = (await query.execute()).results; } else { @@ -125,6 +135,7 @@ var getAuthForLegacySessionToken = function ({ config, sessionToken, installatio var restOptions = { limit: 1, }; + const RestQuery = require('./RestQuery'); var query = new RestQuery(config, master(config), '_User', { sessionToken }, restOptions); return query.execute().then(response => { var results = response.results; @@ -169,6 +180,7 @@ Auth.prototype.getRolesForUser = async function () { objectId: this.user.id, }, }; + const RestQuery = require('./RestQuery'); await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result => results.push(result) ); @@ -262,6 +274,7 @@ Auth.prototype.getRolesByIds = async function (ins) { }; }); const restWhere = { roles: { $in: roles } }; + const RestQuery = require('./RestQuery'); await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result => results.push(result) ); @@ -307,6 +320,156 @@ Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], quer }); }; +const findUsersWithAuthData = (config, authData) => { + const providers = Object.keys(authData); + const query = providers + .reduce((memo, provider) => { + if (!authData[provider] || (authData && !authData[provider].id)) { + return memo; + } + const queryKey = `authData.${provider}.id`; + const query = {}; + query[queryKey] = authData[provider].id; + memo.push(query); + return memo; + }, []) + .filter(q => { + return typeof q !== 'undefined'; + }); + + return query.length > 0 + ? config.database.find('_User', { $or: query }, { limit: 2 }) + : Promise.resolve([]); +}; + +const hasMutatedAuthData = (authData, userAuthData) => { + if (!userAuthData) return { hasMutatedAuthData: true, mutatedAuthData: authData }; + const mutatedAuthData = {}; + Object.keys(authData).forEach(provider => { + // Anonymous provider is not handled this way + if (provider === 'anonymous') return; + const providerData = authData[provider]; + const userProviderAuthData = userAuthData[provider]; + if (!isDeepStrictEqual(providerData, userProviderAuthData)) { + mutatedAuthData[provider] = providerData; + } + }); + const hasMutatedAuthData = Object.keys(mutatedAuthData).length !== 0; + return { hasMutatedAuthData, mutatedAuthData }; +}; + +const checkIfUserHasProvidedConfiguredProvidersForLogin = ( + authData = {}, + userAuthData = {}, + config +) => { + const savedUserProviders = Object.keys(userAuthData).map(provider => ({ + name: provider, + adapter: config.authDataManager.getValidatorForProvider(provider).adapter, + })); + + const hasProvidedASoloProvider = savedUserProviders.some( + provider => + provider && provider.adapter && provider.adapter.policy === 'solo' && authData[provider.name] + ); + + // Solo providers can be considered as safe, so we do not have to check if the user needs + // to provide an additional provider to login. An auth adapter with "solo" (like webauthn) means + // no "additional" auth needs to be provided to login (like OTP, MFA) + if (hasProvidedASoloProvider) { + return; + } + + const additionProvidersNotFound = []; + const hasProvidedAtLeastOneAdditionalProvider = savedUserProviders.some(provider => { + if (provider && provider.adapter && provider.adapter.policy === 'additional') { + if (authData[provider.name]) { + return true; + } else { + // Push missing provider for error message + additionProvidersNotFound.push(provider.name); + } + } + }); + if (hasProvidedAtLeastOneAdditionalProvider || !additionProvidersNotFound.length) { + return; + } + + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + `Missing additional authData ${additionProvidersNotFound.join(',')}` + ); +}; + +// Validate each authData step-by-step and return the provider responses +const handleAuthDataValidation = async (authData, req, foundUser) => { + let user; + if (foundUser) { + user = Parse.User.fromJSON({ className: '_User', ...foundUser }); + // Find user by session and current objectId; only pass user if it's the current user or master key is provided + } else if ( + (req.auth && + req.auth.user && + typeof req.getUserId === 'function' && + req.getUserId() === req.auth.user.id) || + (req.auth && req.auth.isMaster && typeof req.getUserId === 'function' && req.getUserId()) + ) { + user = new Parse.User(); + user.id = req.auth.isMaster ? req.getUserId() : req.auth.user.id; + await user.fetch({ useMasterKey: true }); + } + + const { originalObject, updatedObject } = req.buildParseObjects(); + const requestObject = getRequestObject( + undefined, + req.auth, + updatedObject, + originalObject || user, + req.config + ); + // Perform validation as step-by-step pipeline for better error consistency + // and also to avoid to trigger a provider (like OTP SMS) if another one fails + return reducePromise( + // apply sort to run the pipeline each time in the same order + Object.keys(authData).sort(), + async (acc, provider) => { + if (authData[provider] === null) { + acc.authData[provider] = null; + return acc; + } + const { validator } = req.config.authDataManager.getValidatorForProvider(provider); + const authProvider = (req.config.auth || {})[provider] || {}; + if (authProvider.enabled == null) { + Deprecator.logRuntimeDeprecation({ + usage: `auth.${provider}`, + solution: `auth.${provider}.enabled: true`, + }); + } + if (!validator || authProvider.enabled === false) { + throw new Parse.Error( + Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.' + ); + } + const validationResult = await validator(authData[provider], req, user, requestObject); + if (validationResult) { + if (!Object.keys(validationResult).length) acc.authData[provider] = authData[provider]; + + if (validationResult.response) acc.authDataResponse[provider] = validationResult.response; + // Some auth providers after initialization will avoid to replace authData already stored + if (!validationResult.doNotSave) { + acc.authData[provider] = validationResult.save || authData[provider]; + } + } else { + // Support current authData behavior no result store the new AuthData + acc.authData[provider] = authData[provider]; + } + return acc; + }, + { authData: {}, authDataResponse: {} } + ); +}; + module.exports = { Auth, master, @@ -314,4 +477,9 @@ module.exports = { readOnly, getAuthForSessionToken, getAuthForLegacySessionToken, + findUsersWithAuthData, + hasMutatedAuthData, + checkIfUserHasProvidedConfiguredProvidersForLogin, + reducePromise, + handleAuthDataValidation, }; diff --git a/src/Config.js b/src/Config.js index 04834d3291..da1a744c58 100644 --- a/src/Config.js +++ b/src/Config.js @@ -79,6 +79,7 @@ export class Config { enforcePrivateUsers, schema, requestKeywordDenylist, + allowExpiredAuthDataToken, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -117,6 +118,7 @@ export class Config { this.validateSecurityOptions(security); this.validateSchemaOptions(schema); this.validateEnforcePrivateUsers(enforcePrivateUsers); + this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken); this.validateRequestKeywordDenylist(requestKeywordDenylist); } @@ -134,6 +136,12 @@ export class Config { } } + static validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken) { + if (typeof allowExpiredAuthDataToken !== 'boolean') { + throw 'Parse Server option allowExpiredAuthDataToken must be a boolean.'; + } + } + static validateSecurityOptions(security) { if (Object.prototype.toString.call(security) !== '[object Object]') { throw 'Parse Server option security must be an object.'; diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index 1c3d336be9..e8d1529433 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -24,4 +24,5 @@ module.exports = [ }, { optionKey: 'enforcePrivateUsers', changeNewDefault: 'true' }, { optionKey: 'allowClientClassCreation', changeNewDefault: 'false' }, + { optionKey: 'allowExpiredAuthDataToken', changeNewDefault: 'false' }, ]; diff --git a/src/GraphQL/loaders/parseClassTypes.js b/src/GraphQL/loaders/parseClassTypes.js index df4ed791ea..4f54fe2b2e 100644 --- a/src/GraphQL/loaders/parseClassTypes.js +++ b/src/GraphQL/loaders/parseClassTypes.js @@ -1,3 +1,4 @@ +/* eslint-disable indent */ import { GraphQLID, GraphQLObjectType, @@ -140,11 +141,7 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla ...fields, [field]: { description: `This is the object ${field}.`, - type: - (className === '_User' && (field === 'username' || field === 'password')) || - parseClass.fields[field].required - ? new GraphQLNonNull(type) - : type, + type: parseClass.fields[field].required ? new GraphQLNonNull(type) : type, }, }; } else { @@ -352,6 +349,14 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla const parseObjectFields = { id: globalIdField(className, obj => obj.objectId), ...defaultGraphQLTypes.PARSE_OBJECT_FIELDS, + ...(className === '_User' + ? { + authDataResponse: { + description: `auth provider response when triggered on signUp/logIn.`, + type: defaultGraphQLTypes.OBJECT, + }, + } + : {}), }; const outputFields = () => { return classOutputFields.reduce((fields, field) => { diff --git a/src/GraphQL/loaders/usersMutations.js b/src/GraphQL/loaders/usersMutations.js index c38905cd90..183268a191 100644 --- a/src/GraphQL/loaders/usersMutations.js +++ b/src/GraphQL/loaders/usersMutations.js @@ -41,7 +41,7 @@ const load = parseGraphQLSchema => { req: { config, auth, info }, }); - const { sessionToken, objectId } = await objectsMutations.createObject( + const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject( '_User', parseFields, config, @@ -50,9 +50,15 @@ const load = parseGraphQLSchema => { ); context.info.sessionToken = sessionToken; - + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse; return { - viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId), + viewer, }; } catch (e) { parseGraphQLSchema.handleError(e); @@ -111,7 +117,7 @@ const load = parseGraphQLSchema => { req: { config, auth, info }, }); - const { sessionToken, objectId } = await objectsMutations.createObject( + const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject( '_User', { ...parseFields, authData }, config, @@ -120,9 +126,15 @@ const load = parseGraphQLSchema => { ); context.info.sessionToken = sessionToken; - + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse; return { - viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId), + viewer, }; } catch (e) { parseGraphQLSchema.handleError(e); @@ -146,6 +158,10 @@ const load = parseGraphQLSchema => { description: 'This is the password used to log in the user.', type: new GraphQLNonNull(GraphQLString), }, + authData: { + description: 'Auth data payload, needed if some required auth adapters are configured.', + type: OBJECT, + }, }, outputFields: { viewer: { @@ -155,14 +171,15 @@ const load = parseGraphQLSchema => { }, mutateAndGetPayload: async (args, context, mutationInfo) => { try { - const { username, password } = deepcopy(args); + const { username, password, authData } = deepcopy(args); const { config, auth, info } = context; - const { sessionToken, objectId } = ( + const { sessionToken, objectId, authDataResponse } = ( await usersRouter.handleLogIn({ body: { username, password, + authData, }, query: {}, config, @@ -173,8 +190,15 @@ const load = parseGraphQLSchema => { context.info.sessionToken = sessionToken; + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse; return { - viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId), + viewer, }; } catch (e) { parseGraphQLSchema.handleError(e); @@ -355,6 +379,57 @@ const load = parseGraphQLSchema => { true, true ); + + const challengeMutation = mutationWithClientMutationId({ + name: 'Challenge', + description: + 'The challenge mutation can be used to initiate an authentication challenge when an auth adapter needs it.', + inputFields: { + username: { + description: 'This is the username used to log in the user.', + type: GraphQLString, + }, + password: { + description: 'This is the password used to log in the user.', + type: GraphQLString, + }, + authData: { + description: + 'Auth data allow to preidentify the user if the auth adapter needs preidentification.', + type: OBJECT, + }, + challengeData: { + description: + 'Challenge data payload, can be used to post data to auth providers to auth providers if they need data for the response.', + type: OBJECT, + }, + }, + outputFields: { + challengeData: { + description: 'Challenge response from configured auth adapters.', + type: OBJECT, + }, + }, + mutateAndGetPayload: async (input, context) => { + try { + const { config, auth, info } = context; + + const { response } = await usersRouter.handleChallenge({ + body: input, + config, + auth, + info, + }); + return response; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(challengeMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(challengeMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('challenge', challengeMutation, true, true); }; export { load }; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index c9a316db36..8407b7c7fd 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -68,6 +68,12 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: false, }, + allowExpiredAuthDataToken: { + env: 'PARSE_SERVER_ALLOW_EXPIRED_AUTH_DATA_TOKEN', + help: 'Allow login with expired authData token.', + action: parsers.booleanParser, + default: true, + }, allowHeaders: { env: 'PARSE_SERVER_ALLOW_HEADERS', help: 'Add headers to Access-Control-Allow-Headers', diff --git a/src/Options/docs.js b/src/Options/docs.js index e8601bd4e5..f58421c519 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -14,6 +14,7 @@ * @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts. * @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to true * @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId + * @property {Boolean} allowExpiredAuthDataToken Allow login with expired authData token. * @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers * @property {String} allowOrigin Sets the origin to Access-Control-Allow-Origin * @property {Adapter} analyticsAdapter Adapter module for the analytics diff --git a/src/Options/index.js b/src/Options/index.js index c298bc78e2..6a5ea66b57 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -279,6 +279,9 @@ export interface ParseServerOptions { /* Set to true if new users should be created without public read and write access. :DEFAULT: false */ enforcePrivateUsers: ?boolean; + /* Allow login with expired authData token. + :DEFAULT: true */ + allowExpiredAuthDataToken: ?boolean; /* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. :DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */ requestKeywordDenylist: ?(RequestKeywordDenylist[]); diff --git a/src/RestQuery.js b/src/RestQuery.js index be96683451..1ea92e9fa7 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -590,7 +590,7 @@ RestQuery.prototype.replaceDontSelect = function () { }); }; -const cleanResultAuthData = function (result) { +RestQuery.prototype.cleanResultAuthData = function (result) { delete result.password; if (result.authData) { Object.keys(result.authData).forEach(provider => { @@ -659,7 +659,7 @@ RestQuery.prototype.runFind = function (options = {}) { .then(results => { if (this.className === '_User' && !findOptions.explain) { for (var result of results) { - cleanResultAuthData(result); + this.cleanResultAuthData(result, this.auth, this.config); } } diff --git a/src/RestWrite.js b/src/RestWrite.js index 2be833ad30..54407804f1 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -15,7 +15,6 @@ var ClientSDK = require('./ClientSDK'); import RestQuery from './RestQuery'; import _ from 'lodash'; import logger from './logger'; -import Deprecator from './Deprecator/Deprecator'; import { requiredColumns } from './Controllers/SchemaController'; // query and data are both provided in REST API format. So data @@ -124,6 +123,9 @@ RestWrite.prototype.execute = function () { .then(() => { return this.runBeforeSaveTrigger(); }) + .then(() => { + return this.ensureUniqueAuthDataId(); + }) .then(() => { return this.deleteEmailResetTokenIfNeeded(); }) @@ -159,6 +161,12 @@ RestWrite.prototype.execute = function () { return this.cleanUserAuthData(); }) .then(() => { + // Append the authDataResponse if exists + if (this.authDataResponse) { + if (this.response && this.response.response) { + this.response.response.authDataResponse = this.authDataResponse; + } + } return this.response; }); }; @@ -384,7 +392,11 @@ RestWrite.prototype.validateAuthData = function () { return; } - if (!this.query && !this.data.authData) { + const authData = this.data.authData; + const hasUsernameAndPassword = + typeof this.data.username === 'string' && typeof this.data.password === 'string'; + + if (!this.query && !authData) { if (typeof this.data.username !== 'string' || _.isEmpty(this.data.username)) { throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username'); } @@ -394,10 +406,10 @@ RestWrite.prototype.validateAuthData = function () { } if ( - (this.data.authData && !Object.keys(this.data.authData).length) || + (authData && !Object.keys(authData).length) || !Object.prototype.hasOwnProperty.call(this.data, 'authData') ) { - // Handle saving authData to {} or if authData doesn't exist + // Nothing to validate here return; } else if (Object.prototype.hasOwnProperty.call(this.data, 'authData') && !this.data.authData) { // Handle saving authData to null @@ -407,15 +419,14 @@ RestWrite.prototype.validateAuthData = function () { ); } - var authData = this.data.authData; var providers = Object.keys(authData); if (providers.length > 0) { - const canHandleAuthData = providers.reduce((canHandle, provider) => { + const canHandleAuthData = providers.some(provider => { var providerAuthData = authData[provider]; var hasToken = providerAuthData && providerAuthData.id; - return canHandle && (hasToken || providerAuthData == null); - }, true); - if (canHandleAuthData) { + return hasToken || providerAuthData === null; + }); + if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) { return this.handleAuthData(authData); } } @@ -425,55 +436,6 @@ RestWrite.prototype.validateAuthData = function () { ); }; -RestWrite.prototype.handleAuthDataValidation = function (authData) { - const validations = Object.keys(authData).map(provider => { - if (authData[provider] === null) { - return Promise.resolve(); - } - const validateAuthData = this.config.authDataManager.getValidatorForProvider(provider); - const authProvider = (this.config.auth || {})[provider] || {}; - if (authProvider.enabled == null) { - Deprecator.logRuntimeDeprecation({ - usage: `auth.${provider}`, - solution: `auth.${provider}.enabled: true`, - }); - } - if (!validateAuthData || authProvider.enabled === false) { - throw new Parse.Error( - Parse.Error.UNSUPPORTED_SERVICE, - 'This authentication method is unsupported.' - ); - } - return validateAuthData(authData[provider]); - }); - return Promise.all(validations); -}; - -RestWrite.prototype.findUsersWithAuthData = function (authData) { - const providers = Object.keys(authData); - const query = providers - .reduce((memo, provider) => { - if (!authData[provider]) { - return memo; - } - const queryKey = `authData.${provider}.id`; - const query = {}; - query[queryKey] = authData[provider].id; - memo.push(query); - return memo; - }, []) - .filter(q => { - return typeof q !== 'undefined'; - }); - - let findPromise = Promise.resolve([]); - if (query.length > 0) { - findPromise = this.config.database.find(this.className, { $or: query }, {}); - } - - return findPromise; -}; - RestWrite.prototype.filteredObjectsByACL = function (objects) { if (this.auth.isMaster) { return objects; @@ -487,106 +449,161 @@ RestWrite.prototype.filteredObjectsByACL = function (objects) { }); }; -RestWrite.prototype.handleAuthData = function (authData) { - let results; - return this.findUsersWithAuthData(authData).then(async r => { - results = this.filteredObjectsByACL(r); - - if (results.length == 1) { - this.storage['authProvider'] = Object.keys(authData).join(','); - - const userResult = results[0]; - const mutatedAuthData = {}; - Object.keys(authData).forEach(provider => { - const providerData = authData[provider]; - const userAuthData = userResult.authData[provider]; - if (!_.isEqual(providerData, userAuthData)) { - mutatedAuthData[provider] = providerData; - } - }); - const hasMutatedAuthData = Object.keys(mutatedAuthData).length !== 0; - let userId; - if (this.query && this.query.objectId) { - userId = this.query.objectId; - } else if (this.auth && this.auth.user && this.auth.user.id) { - userId = this.auth.user.id; +RestWrite.prototype.getUserId = function () { + if (this.query && this.query.objectId && this.className === '_User') { + return this.query.objectId; + } else if (this.auth && this.auth.user && this.auth.user.id) { + return this.auth.user.id; + } +}; + +// Developers are allowed to change authData via before save trigger +// we need after before save to ensure that the developer +// is not currently duplicating auth data ID +RestWrite.prototype.ensureUniqueAuthDataId = async function () { + if (this.className !== '_User' || !this.data.authData) { + return; + } + + const hasAuthDataId = Object.keys(this.data.authData).some( + key => this.data.authData[key] && this.data.authData[key].id + ); + + if (!hasAuthDataId) return; + + const r = await Auth.findUsersWithAuthData(this.config, this.data.authData); + const results = this.filteredObjectsByACL(r); + if (results.length > 1) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } + // use data.objectId in case of login time and found user during handle validateAuthData + const userId = this.getUserId() || this.data.objectId; + if (results.length === 1 && userId !== results[0].objectId) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } +}; + +RestWrite.prototype.handleAuthData = async function (authData) { + const r = await Auth.findUsersWithAuthData(this.config, authData); + const results = this.filteredObjectsByACL(r); + + if (results.length > 1) { + // To avoid https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5 + // Let's run some validation before throwing + await Auth.handleAuthDataValidation(authData, this, results[0]); + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } + + // No user found with provided authData we need to validate + if (!results.length) { + const { authData: validatedAuthData, authDataResponse } = await Auth.handleAuthDataValidation( + authData, + this + ); + this.authDataResponse = authDataResponse; + // Replace current authData by the new validated one + this.data.authData = validatedAuthData; + return; + } + + // User found with provided authData + if (results.length === 1) { + const userId = this.getUserId(); + const userResult = results[0]; + // Prevent duplicate authData id + if (userId && userId !== userResult.objectId) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } + + this.storage.authProvider = Object.keys(authData).join(','); + + const { hasMutatedAuthData, mutatedAuthData } = Auth.hasMutatedAuthData( + authData, + userResult.authData + ); + + const isCurrentUserLoggedOrMaster = + (this.auth && this.auth.user && this.auth.user.id === userResult.objectId) || + this.auth.isMaster; + + const isLogin = !userId; + + if (isLogin || isCurrentUserLoggedOrMaster) { + // no user making the call + // OR the user making the call is the right one + // Login with auth data + delete results[0].password; + + // need to set the objectId first otherwise location has trailing undefined + this.data.objectId = userResult.objectId; + + if (!this.query || !this.query.objectId) { + this.response = { + response: userResult, + location: this.location(), + }; + // Run beforeLogin hook before storing any updates + // to authData on the db; changes to userResult + // will be ignored. + await this.runBeforeLoginTrigger(deepcopy(userResult)); + + // If we are in login operation via authData + // we need to be sure that the user has provided + // required authData + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( + authData, + userResult.authData, + this.config + ); } - if (!userId || userId === userResult.objectId) { - // no user making the call - // OR the user making the call is the right one - // Login with auth data - delete results[0].password; - - // need to set the objectId first otherwise location has trailing undefined - this.data.objectId = userResult.objectId; - - if (!this.query || !this.query.objectId) { - // this a login call, no userId passed - this.response = { - response: userResult, - location: this.location(), - }; - // Run beforeLogin hook before storing any updates - // to authData on the db; changes to userResult - // will be ignored. - await this.runBeforeLoginTrigger(deepcopy(userResult)); - } - // If we didn't change the auth data, just keep going - if (!hasMutatedAuthData) { - return; - } - // We have authData that is updated on login - // that can happen when token are refreshed, - // We should update the token and let the user in - // We should only check the mutated keys - return this.handleAuthDataValidation(mutatedAuthData).then(async () => { - // IF we have a response, we'll skip the database operation / beforeSave / afterSave etc... - // we need to set it up there. - // We are supposed to have a response only on LOGIN with authData, so we skip those - // If we're not logging in, but just updating the current user, we can safely skip that part - if (this.response) { - // Assign the new authData in the response - Object.keys(mutatedAuthData).forEach(provider => { - this.response.response.authData[provider] = mutatedAuthData[provider]; - }); + // Prevent validating if no mutated data detected on update + if (!hasMutatedAuthData && isCurrentUserLoggedOrMaster) { + return; + } - // Run the DB update directly, as 'master' - // Just update the authData part - // Then we're good for the user, early exit of sorts - return this.config.database.update( - this.className, - { objectId: this.data.objectId }, - { authData: mutatedAuthData }, - {} - ); - } + // Force to validate all provided authData on login + // on update only validate mutated ones + if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { + const res = await Auth.handleAuthDataValidation( + isLogin ? authData : mutatedAuthData, + this, + userResult + ); + this.data.authData = res.authData; + this.authDataResponse = res.authDataResponse; + } + + // IF we are in login we'll skip the database operation / beforeSave / afterSave etc... + // we need to set it up there. + // We are supposed to have a response only on LOGIN with authData, so we skip those + // If we're not logging in, but just updating the current user, we can safely skip that part + if (this.response) { + // Assign the new authData in the response + Object.keys(mutatedAuthData).forEach(provider => { + this.response.response.authData[provider] = mutatedAuthData[provider]; }); - } else if (userId) { - // Trying to update auth data but users - // are different - if (userResult.objectId !== userId) { - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); - } - // No auth data was mutated, just keep going - if (!hasMutatedAuthData) { - return; + + // Run the DB update directly, as 'master' only if authData contains some keys + // authData could not contains keys after validation if the authAdapter + // uses the `doNotSave` option. Just update the authData part + // Then we're good for the user, early exit of sorts + if (Object.keys(this.data.authData).length) { + await this.config.database.update( + this.className, + { objectId: this.data.objectId }, + { authData: this.data.authData }, + {} + ); } } } - return this.handleAuthDataValidation(authData).then(() => { - if (results.length > 1) { - // More than 1 user with the passed id's - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); - } - }); - }); + } }; // The non-third-party parts of User transformation RestWrite.prototype.transformUser = function () { var promise = Promise.resolve(); - if (this.className !== '_User') { return promise; } @@ -857,7 +874,7 @@ RestWrite.prototype.createSessionTokenIfNeeded = function () { return; } if ( - !this.storage['authProvider'] && // signup call, with + !this.storage.authProvider && // signup call, with this.config.preventLoginWithUnverifiedEmail && // no login without verification this.config.verifyUserEmails ) { @@ -874,15 +891,15 @@ RestWrite.prototype.createSessionToken = async function () { return; } - if (this.storage['authProvider'] == null && this.data.authData) { - this.storage['authProvider'] = Object.keys(this.data.authData).join(','); + if (this.storage.authProvider == null && this.data.authData) { + this.storage.authProvider = Object.keys(this.data.authData).join(','); } const { sessionData, createSession } = RestWrite.createSession(this.config, { userId: this.objectId(), createdWith: { - action: this.storage['authProvider'] ? 'login' : 'signup', - authProvider: this.storage['authProvider'] || 'password', + action: this.storage.authProvider ? 'login' : 'signup', + authProvider: this.storage.authProvider || 'password', }, installationId: this.auth.installationId, }); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index cdce6a1348..55bcb37e7f 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -7,9 +7,10 @@ import ClassesRouter from './ClassesRouter'; import rest from '../rest'; import Auth from '../Auth'; import passwordCrypto from '../password'; -import { maybeRunTrigger, Types as TriggerTypes } from '../triggers'; +import { maybeRunTrigger, Types as TriggerTypes, getRequestObject } from '../triggers'; import { promiseEnsureIdempotency } from '../middlewares'; import RestWrite from '../RestWrite'; +import { logger } from '../../lib/Adapters/Logger/WinstonLogger'; export class UsersRouter extends ClassesRouter { className() { @@ -174,7 +175,6 @@ export class UsersRouter extends ClassesRouter { // Remove hidden properties. UsersRouter.removeHiddenProperties(user); - return { response: user }; } }); @@ -182,6 +182,30 @@ export class UsersRouter extends ClassesRouter { async handleLogIn(req) { const user = await this._authenticateUserFromRequest(req); + const authData = req.body && req.body.authData; + // Check if user has provided their required auth providers + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(authData, user.authData, req.config); + + let authDataResponse; + let validatedAuthData; + if (authData) { + const res = await Auth.handleAuthDataValidation( + authData, + new RestWrite( + req.config, + req.auth, + '_User', + { objectId: user.objectId }, + req.body, + user, + req.info.clientSDK, + req.info.context + ), + user + ); + authDataResponse = res.authDataResponse; + validatedAuthData = res.authData; + } // handle password expiry policy if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) { @@ -228,6 +252,16 @@ export class UsersRouter extends ClassesRouter { req.config ); + // If we have some new validated authData update directly + if (validatedAuthData && Object.keys(validatedAuthData).length) { + await req.config.database.update( + '_User', + { objectId: user.objectId }, + { authData: validatedAuthData }, + {} + ); + } + const { sessionData, createSession } = RestWrite.createSession(req.config, { userId: user.objectId, createdWith: { @@ -250,6 +284,10 @@ export class UsersRouter extends ClassesRouter { req.config ); + if (authDataResponse) { + user.authDataResponse = authDataResponse; + } + return { response: user }; } @@ -453,6 +491,106 @@ export class UsersRouter extends ClassesRouter { }); } + async handleChallenge(req) { + const { username, email, password, authData, challengeData } = req.body; + + // if username or email provided with password try to authenticate the user by username + let user; + if (username || email) { + if (!password) + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You provided username or email, you need to also provide password.' + ); + user = await this._authenticateUserFromRequest(req); + } + + if (!challengeData) throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Nothing to challenge.'); + + if (typeof challengeData !== 'object') + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'challengeData should be an object.'); + + let request; + let parseUser; + + // Try to find user by authData + if (authData) { + if (typeof authData !== 'object') { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'authData should be an object.'); + } + if (user) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You cannot provide username/email and authData, only use one identification method.' + ); + } + + if (Object.keys(authData).filter(key => authData[key].id).length > 1) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You cannot provide more than one authData provider with an id.' + ); + } + + const results = await Auth.findUsersWithAuthData(req.config, authData); + + try { + if (!results[0] || results.length > 1) { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'User not found.'); + } + // Find the provider used to find the user + const provider = Object.keys(authData).find(key => authData[key].id); + + parseUser = Parse.User.fromJSON({ className: '_User', ...results[0] }); + request = getRequestObject(undefined, req.auth, parseUser, parseUser, req.config); + request.isChallenge = true; + // Validate authData used to identify the user to avoid brute-force attack on `id` + const { validator } = req.config.authDataManager.getValidatorForProvider(provider); + await validator(authData[provider], req, parseUser, request); + } catch (e) { + // Rewrite the error to avoid guess id attack + logger.error(e); + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'User not found.'); + } + } + + if (!parseUser) { + parseUser = user ? Parse.User.fromJSON({ className: '_User', ...user }) : undefined; + } + + if (!request) { + request = getRequestObject(undefined, req.auth, parseUser, parseUser, req.config); + request.isChallenge = true; + } + + // Execute challenge step-by-step with consistent order for better error feedback + // and to avoid to trigger others challenges if one of them fails + const challenge = await Auth.reducePromise( + Object.keys(challengeData).sort(), + async (acc, provider) => { + const authAdapter = req.config.authDataManager.getValidatorForProvider(provider); + if (!authAdapter) return acc; + const { + adapter: { challenge }, + } = authAdapter; + if (typeof challenge === 'function') { + const providerChallengeResponse = await challenge( + challengeData[provider], + authData && authData[provider], + req.config.auth[provider], + request, + req.config + ); + acc[provider] = providerChallengeResponse || true; + return acc; + } + }, + {} + ); + + return { response: { challengeData: challenge } }; + } + mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); @@ -493,6 +631,9 @@ export class UsersRouter extends ClassesRouter { this.route('GET', '/verifyPassword', req => { return this.handleVerifyPassword(req); }); + this.route('POST', '/challenge', req => { + return this.handleChallenge(req); + }); } } diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 1ee02fdb60..b90ffe70f6 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -728,6 +728,7 @@ module.exports = ParseCloud; * @interface Parse.Cloud.TriggerRequest * @property {String} installationId If set, the installationId triggering the request. * @property {Boolean} master If true, means the master key was used. + * @property {Boolean} isChallenge If true, means the current request is originally triggered by an auth challenge. * @property {Parse.User} user If set, the user that made the request. * @property {Parse.Object} object The object triggering the hook. * @property {String} ip The IP address of the client making the request.