diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 0b88ead0..af9de4ef 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -2021,6 +2021,24 @@ describe('afterFind hooks', () => { expect(() => { Parse.Cloud.afterSave('_PushStatus', () => {}); }).not.toThrow(); + expect(() => { + Parse.Cloud.beforeLogin(() => {}); + }).not.toThrow( + 'Only the _User class is allowed for the beforeLogin trigger' + ); + expect(() => { + Parse.Cloud.beforeLogin('_User', () => {}); + }).not.toThrow( + 'Only the _User class is allowed for the beforeLogin trigger' + ); + expect(() => { + Parse.Cloud.beforeLogin(Parse.User, () => {}); + }).not.toThrow( + 'Only the _User class is allowed for the beforeLogin trigger' + ); + expect(() => { + Parse.Cloud.beforeLogin('SomeClass', () => {}); + }).toThrow('Only the _User class is allowed for the beforeLogin trigger'); }); it('should skip afterFind hooks for aggregate', done => { @@ -2115,3 +2133,88 @@ describe('afterFind hooks', () => { expect(calledAfter).toBe(true); }); }); + +describe('beforeLogin hook', () => { + it('should run beforeLogin with correct credentials', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('tupac'); + }); + + await Parse.User.signUp('tupac', 'shakur'); + const user = await Parse.User.logIn('tupac', 'shakur'); + expect(hit).toBe(1); + expect(user).toBeDefined(); + expect(user.getUsername()).toBe('tupac'); + expect(user.getSessionToken()).toBeDefined(); + done(); + }); + + it('should be able to block login if an error is thrown', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + if (req.object.get('isBanned')) { + throw new Error('banned account'); + } + }); + + const user = await Parse.User.signUp('tupac', 'shakur'); + await user.save({ isBanned: true }); + + try { + await Parse.User.logIn('tupac', 'shakur'); + throw new Error('should not have been logged in.'); + } catch (e) { + expect(e.message).toBe('banned account'); + } + expect(hit).toBe(1); + done(); + }); + + it('should not run beforeLogin with incorrect credentials', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('tupac'); + }); + + await Parse.User.signUp('tupac', 'shakur'); + try { + await Parse.User.logIn('tony', 'shakur'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + expect(hit).toBe(0); + done(); + }); + + it('should not run beforeLogin on sign up', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('tupac'); + }); + + const user = await Parse.User.signUp('tupac', 'shakur'); + expect(user).toBeDefined(); + expect(hit).toBe(0); + done(); + }); + + it('should have expected data in request', async done => { + Parse.Cloud.beforeLogin(req => { + expect(req.object).toBeDefined(); + expect(req.user).toBeUndefined(); + expect(req.headers).toBeDefined(); + expect(req.ip).toBeDefined(); + expect(req.installationId).toBeDefined(); + expect(req.context).toBeUndefined(); + }); + + await Parse.User.signUp('tupac', 'shakur'); + await Parse.User.logIn('tupac', 'shakur'); + done(); + }); +}); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 05429bf5..f21403e8 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -440,6 +440,22 @@ describe('Parse.User testing', () => { ); }); + it('should not call beforeLogin with become', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + + let hit = 0; + Parse.Cloud.beforeLogin(() => { + hit++; + }); + + await Parse.User._logInWith('facebook'); + const sessionToken = Parse.User.current().getSessionToken(); + await Parse.User.become(sessionToken); + expect(hit).toBe(0); + done(); + }); + it('cannot save non-authed user', async done => { let user = new Parse.User(); user.set({ @@ -1403,6 +1419,84 @@ describe('Parse.User testing', () => { }); }); + it('signup with provider should not call beforeLogin trigger', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + + let hit = 0; + Parse.Cloud.beforeLogin(() => { + hit++; + }); + + await Parse.User._logInWith('facebook'); + expect(hit).toBe(0); + done(); + }); + + it('login with provider should call beforeLogin trigger', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('authData')).toBeDefined(); + expect(req.object.get('name')).toBe('tupac shakur'); + }); + await Parse.User._logInWith('facebook'); + await Parse.User.current().save({ name: 'tupac shakur' }); + await Parse.User.logOut(); + await Parse.User._logInWith('facebook'); + expect(hit).toBe(1); + done(); + }); + + it('incorrect login with provider should not call beforeLogin trigger', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + + let hit = 0; + Parse.Cloud.beforeLogin(() => { + hit++; + }); + await Parse.User._logInWith('facebook'); + await Parse.User.logOut(); + provider.shouldError = true; + try { + await Parse.User._logInWith('facebook'); + } catch (e) { + expect(e).toBeDefined(); + } + expect(hit).toBe(0); + done(); + }); + + it('login with provider should be blockable by beforeLogin', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + if (req.object.get('isBanned')) { + throw new Error('banned account'); + } + }); + await Parse.User._logInWith('facebook'); + await Parse.User.current().save({ isBanned: true }); + await Parse.User.logOut(); + + try { + await Parse.User._logInWith('facebook'); + throw new Error('should not have continued login.'); + } catch (e) { + expect(e.message).toBe('banned account'); + } + + expect(hit).toBe(1); + done(); + }); + it('link with provider', async done => { const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); diff --git a/src/RestWrite.js b/src/RestWrite.js index 041dbd50..09fa497b 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -93,7 +93,7 @@ RestWrite.prototype.execute = function() { return this.validateAuthData(); }) .then(() => { - return this.runBeforeTrigger(); + return this.runBeforeSaveTrigger(); }) .then(() => { return this.deleteEmailResetTokenIfNeeded(); @@ -123,7 +123,7 @@ RestWrite.prototype.execute = function() { return this.handleFollowup(); }) .then(() => { - return this.runAfterTrigger(); + return this.runAfterSaveTrigger(); }) .then(() => { return this.cleanUserAuthData(); @@ -190,7 +190,7 @@ RestWrite.prototype.validateSchema = function() { // Runs any beforeSave triggers against this operation. // Any change leads to our data being mutated. -RestWrite.prototype.runBeforeTrigger = function() { +RestWrite.prototype.runBeforeSaveTrigger = function() { if (this.response) { return; } @@ -251,6 +251,33 @@ RestWrite.prototype.runBeforeTrigger = function() { }); }; +RestWrite.prototype.runBeforeLoginTrigger = async function(userData) { + // Avoid doing any setup for triggers if there is no 'beforeLogin' trigger + if ( + !triggers.triggerExists( + this.className, + triggers.Types.beforeLogin, + this.config.applicationId + ) + ) { + return; + } + + // Cloud code gets a bit of extra data for its objects + const extraData = { className: this.className }; + const user = triggers.inflate(extraData, userData); + + // no need to return a response + await triggers.maybeRunTrigger( + triggers.Types.beforeLogin, + this.auth, + user, + null, + this.config, + this.context + ); +}; + RestWrite.prototype.setRequiredFieldsIfNeeded = function() { if (this.data) { // Add default fields @@ -377,7 +404,7 @@ RestWrite.prototype.filteredObjectsByACL = function(objects) { RestWrite.prototype.handleAuthData = function(authData) { let results; - return this.findUsersWithAuthData(authData).then(r => { + return this.findUsersWithAuthData(authData).then(async r => { results = this.filteredObjectsByACL(r); if (results.length > 1) { // More than 1 user with the passed id's @@ -421,7 +448,12 @@ RestWrite.prototype.handleAuthData = function(authData) { 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; @@ -430,7 +462,7 @@ RestWrite.prototype.handleAuthData = function(authData) { // 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(() => { + 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 @@ -441,6 +473,7 @@ RestWrite.prototype.handleAuthData = function(authData) { this.response.response.authData[provider] = mutatedAuthData[provider]; }); + // Run the DB update directly, as 'master' // Just update the authData part // Then we're good for the user, early exit of sorts @@ -1415,7 +1448,7 @@ RestWrite.prototype.runDatabaseOperation = function() { }; // Returns nothing - doesn't wait for the trigger. -RestWrite.prototype.runAfterTrigger = function() { +RestWrite.prototype.runAfterSaveTrigger = function() { if (!this.response || !this.response.response) { return; } diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index b7c02fa2..3cbe6ce9 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -7,6 +7,7 @@ import ClassesRouter from './ClassesRouter'; import rest from '../rest'; import Auth from '../Auth'; import passwordCrypto from '../password'; +import { maybeRunTrigger, Types as TriggerTypes } from '../triggers'; export class UsersRouter extends ClassesRouter { className() { @@ -202,68 +203,68 @@ export class UsersRouter extends ClassesRouter { }); } - handleLogIn(req) { - let user; - return this._authenticateUserFromRequest(req) - .then(res => { - user = res; + async handleLogIn(req) { + const user = await this._authenticateUserFromRequest(req); - // handle password expiry policy - if ( - req.config.passwordPolicy && - req.config.passwordPolicy.maxPasswordAge - ) { - let changedAt = user._password_changed_at; + // handle password expiry policy + if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) { + let changedAt = user._password_changed_at; - if (!changedAt) { - // password was created before expiry policy was enabled. - // simply update _User object so that it will start enforcing from now - changedAt = new Date(); - req.config.database.update( - '_User', - { username: user.username }, - { _password_changed_at: Parse._encode(changedAt) } - ); - } else { - // check whether the password has expired - if (changedAt.__type == 'Date') { - changedAt = new Date(changedAt.iso); - } - // Calculate the expiry time. - const expiresAt = new Date( - changedAt.getTime() + - 86400000 * req.config.passwordPolicy.maxPasswordAge - ); - if (expiresAt < new Date()) - // fail of current time is past password expiry time - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Your password has expired. Please reset your password.' - ); - } + if (!changedAt) { + // password was created before expiry policy was enabled. + // simply update _User object so that it will start enforcing from now + changedAt = new Date(); + req.config.database.update( + '_User', + { username: user.username }, + { _password_changed_at: Parse._encode(changedAt) } + ); + } else { + // check whether the password has expired + if (changedAt.__type == 'Date') { + changedAt = new Date(changedAt.iso); } + // Calculate the expiry time. + const expiresAt = new Date( + changedAt.getTime() + + 86400000 * req.config.passwordPolicy.maxPasswordAge + ); + if (expiresAt < new Date()) + // fail of current time is past password expiry time + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Your password has expired. Please reset your password.' + ); + } + } - // Remove hidden properties. - UsersRouter.removeHiddenProperties(user); + // Remove hidden properties. + UsersRouter.removeHiddenProperties(user); - const { sessionData, createSession } = Auth.createSession(req.config, { - userId: user.objectId, - createdWith: { - action: 'login', - authProvider: 'password', - }, - installationId: req.info.installationId, - }); + // Before login trigger; throws if failure + await maybeRunTrigger( + TriggerTypes.beforeLogin, + req.auth, + Parse.User.fromJSON(Object.assign({ className: '_User' }, user)), + null, + req.config + ); - user.sessionToken = sessionData.sessionToken; + const { sessionData, createSession } = Auth.createSession(req.config, { + userId: user.objectId, + createdWith: { + action: 'login', + authProvider: 'password', + }, + installationId: req.info.installationId, + }); - req.config.filesController.expandFilesInObject(req.config, user); + user.sessionToken = sessionData.sessionToken; - return createSession(); - }) - .then(() => { - return { response: user }; - }); + req.config.filesController.expandFilesInObject(req.config, user); + + await createSession(); + return { response: user }; } handleVerifyPassword(req) { diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index a24a2ab2..19a1b550 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -1,6 +1,10 @@ import { Parse } from 'parse/node'; import * as triggers from '../triggers'; +function isParseObjectConstructor(object) { + return typeof object === 'function' && object.hasOwnProperty('className'); +} + function getClassName(parseClass) { if (parseClass && parseClass.className) { return parseClass.className; @@ -119,6 +123,45 @@ ParseCloud.beforeDelete = function(parseClass, handler) { ); }; +/** + * + * Registers the before login function. + * + * **Available in Cloud Code only.** + * + * This function provides further control + * in validating a login attempt. Specifically, + * it is triggered after a user enters + * correct credentials (or other valid authData), + * but prior to a session being generated. + * + * ``` + * Parse.Cloud.beforeLogin((request) => { + * // code here + * }) + * + * ``` + * + * @method beforeLogin + * @name Parse.Cloud.beforeLogin + * @param {Function} func The function to run before a login. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; + */ +ParseCloud.beforeLogin = function(handler) { + let className = '_User'; + if (typeof handler === 'string' || isParseObjectConstructor(handler)) { + // validation will occur downstream, this is to maintain internal + // code consistency with the other hook types. + className = getClassName(handler); + handler = arguments[1]; + } + triggers.addTrigger( + triggers.Types.beforeLogin, + className, + handler, + Parse.applicationId + ); +}; + /** * Registers an after save function. * diff --git a/src/triggers.js b/src/triggers.js index b8558e21..c0a94c5e 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -3,6 +3,7 @@ import Parse from 'parse/node'; import { logger } from './logger'; export const Types = { + beforeLogin: 'beforeLogin', beforeSave: 'beforeSave', afterSave: 'afterSave', beforeDelete: 'beforeDelete', @@ -41,6 +42,11 @@ function validateClassNameForTriggers(className, type) { // TODO: Allow proper documented way of using nested increment ops throw 'Only afterSave is allowed on _PushStatus'; } + if (type === Types.beforeLogin && className !== '_User') { + // TODO: check if upstream code will handle `Error` instance rather + // than this anti-pattern of throwing strings + throw 'Only the _User class is allowed for the beforeLogin trigger'; + } return className; }