Add beforeLogin trigger with support for auth providers (#5445)

* Add beforeLogin trigger with support for auth providers

* adjust comment that boxed off beforeLogin to a negative use-case only

* add internal error to help future maintainers regarding use of beforeLogin

* let beforeLogin accept className or constructor like other hook types

* add assertions for beforeLogin trigger className validation
This commit is contained in:
Omair Vaiyani
2019-04-23 16:24:20 +01:00
committed by Arthur Cinader
parent 3e003ee9f4
commit a1e1cef6d2
6 changed files with 340 additions and 60 deletions

View File

@@ -2021,6 +2021,24 @@ describe('afterFind hooks', () => {
expect(() => { expect(() => {
Parse.Cloud.afterSave('_PushStatus', () => {}); Parse.Cloud.afterSave('_PushStatus', () => {});
}).not.toThrow(); }).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 => { it('should skip afterFind hooks for aggregate', done => {
@@ -2115,3 +2133,88 @@ describe('afterFind hooks', () => {
expect(calledAfter).toBe(true); 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();
});
});

View File

@@ -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 => { it('cannot save non-authed user', async done => {
let user = new Parse.User(); let user = new Parse.User();
user.set({ 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 => { it('link with provider', async done => {
const provider = getMockFacebookProvider(); const provider = getMockFacebookProvider();
Parse.User._registerAuthenticationProvider(provider); Parse.User._registerAuthenticationProvider(provider);

View File

@@ -93,7 +93,7 @@ RestWrite.prototype.execute = function() {
return this.validateAuthData(); return this.validateAuthData();
}) })
.then(() => { .then(() => {
return this.runBeforeTrigger(); return this.runBeforeSaveTrigger();
}) })
.then(() => { .then(() => {
return this.deleteEmailResetTokenIfNeeded(); return this.deleteEmailResetTokenIfNeeded();
@@ -123,7 +123,7 @@ RestWrite.prototype.execute = function() {
return this.handleFollowup(); return this.handleFollowup();
}) })
.then(() => { .then(() => {
return this.runAfterTrigger(); return this.runAfterSaveTrigger();
}) })
.then(() => { .then(() => {
return this.cleanUserAuthData(); return this.cleanUserAuthData();
@@ -190,7 +190,7 @@ RestWrite.prototype.validateSchema = function() {
// Runs any beforeSave triggers against this operation. // Runs any beforeSave triggers against this operation.
// Any change leads to our data being mutated. // Any change leads to our data being mutated.
RestWrite.prototype.runBeforeTrigger = function() { RestWrite.prototype.runBeforeSaveTrigger = function() {
if (this.response) { if (this.response) {
return; 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() { RestWrite.prototype.setRequiredFieldsIfNeeded = function() {
if (this.data) { if (this.data) {
// Add default fields // Add default fields
@@ -377,7 +404,7 @@ RestWrite.prototype.filteredObjectsByACL = function(objects) {
RestWrite.prototype.handleAuthData = function(authData) { RestWrite.prototype.handleAuthData = function(authData) {
let results; let results;
return this.findUsersWithAuthData(authData).then(r => { return this.findUsersWithAuthData(authData).then(async r => {
results = this.filteredObjectsByACL(r); results = this.filteredObjectsByACL(r);
if (results.length > 1) { if (results.length > 1) {
// More than 1 user with the passed id's // More than 1 user with the passed id's
@@ -421,7 +448,12 @@ RestWrite.prototype.handleAuthData = function(authData) {
response: userResult, response: userResult,
location: this.location(), 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 we didn't change the auth data, just keep going
if (!hasMutatedAuthData) { if (!hasMutatedAuthData) {
return; return;
@@ -430,7 +462,7 @@ RestWrite.prototype.handleAuthData = function(authData) {
// that can happen when token are refreshed, // that can happen when token are refreshed,
// We should update the token and let the user in // We should update the token and let the user in
// We should only check the mutated keys // 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... // IF we have a response, we'll skip the database operation / beforeSave / afterSave etc...
// we need to set it up there. // we need to set it up there.
// We are supposed to have a response only on LOGIN with authData, so we skip those // 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] = this.response.response.authData[provider] =
mutatedAuthData[provider]; mutatedAuthData[provider];
}); });
// Run the DB update directly, as 'master' // Run the DB update directly, as 'master'
// Just update the authData part // Just update the authData part
// Then we're good for the user, early exit of sorts // 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. // Returns nothing - doesn't wait for the trigger.
RestWrite.prototype.runAfterTrigger = function() { RestWrite.prototype.runAfterSaveTrigger = function() {
if (!this.response || !this.response.response) { if (!this.response || !this.response.response) {
return; return;
} }

View File

@@ -7,6 +7,7 @@ import ClassesRouter from './ClassesRouter';
import rest from '../rest'; import rest from '../rest';
import Auth from '../Auth'; import Auth from '../Auth';
import passwordCrypto from '../password'; import passwordCrypto from '../password';
import { maybeRunTrigger, Types as TriggerTypes } from '../triggers';
export class UsersRouter extends ClassesRouter { export class UsersRouter extends ClassesRouter {
className() { className() {
@@ -202,68 +203,68 @@ export class UsersRouter extends ClassesRouter {
}); });
} }
handleLogIn(req) { async handleLogIn(req) {
let user; const user = await this._authenticateUserFromRequest(req);
return this._authenticateUserFromRequest(req)
.then(res => {
user = res;
// handle password expiry policy // handle password expiry policy
if ( if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) {
req.config.passwordPolicy && let changedAt = user._password_changed_at;
req.config.passwordPolicy.maxPasswordAge
) {
let changedAt = user._password_changed_at;
if (!changedAt) { if (!changedAt) {
// password was created before expiry policy was enabled. // password was created before expiry policy was enabled.
// simply update _User object so that it will start enforcing from now // simply update _User object so that it will start enforcing from now
changedAt = new Date(); changedAt = new Date();
req.config.database.update( req.config.database.update(
'_User', '_User',
{ username: user.username }, { username: user.username },
{ _password_changed_at: Parse._encode(changedAt) } { _password_changed_at: Parse._encode(changedAt) }
); );
} else { } else {
// check whether the password has expired // check whether the password has expired
if (changedAt.__type == 'Date') { if (changedAt.__type == 'Date') {
changedAt = new Date(changedAt.iso); 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.'
);
}
} }
// 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. // Remove hidden properties.
UsersRouter.removeHiddenProperties(user); UsersRouter.removeHiddenProperties(user);
const { sessionData, createSession } = Auth.createSession(req.config, { // Before login trigger; throws if failure
userId: user.objectId, await maybeRunTrigger(
createdWith: { TriggerTypes.beforeLogin,
action: 'login', req.auth,
authProvider: 'password', Parse.User.fromJSON(Object.assign({ className: '_User' }, user)),
}, null,
installationId: req.info.installationId, 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(); req.config.filesController.expandFilesInObject(req.config, user);
})
.then(() => { await createSession();
return { response: user }; return { response: user };
});
} }
handleVerifyPassword(req) { handleVerifyPassword(req) {

View File

@@ -1,6 +1,10 @@
import { Parse } from 'parse/node'; import { Parse } from 'parse/node';
import * as triggers from '../triggers'; import * as triggers from '../triggers';
function isParseObjectConstructor(object) {
return typeof object === 'function' && object.hasOwnProperty('className');
}
function getClassName(parseClass) { function getClassName(parseClass) {
if (parseClass && parseClass.className) { if (parseClass && parseClass.className) {
return 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. * Registers an after save function.
* *

View File

@@ -3,6 +3,7 @@ import Parse from 'parse/node';
import { logger } from './logger'; import { logger } from './logger';
export const Types = { export const Types = {
beforeLogin: 'beforeLogin',
beforeSave: 'beforeSave', beforeSave: 'beforeSave',
afterSave: 'afterSave', afterSave: 'afterSave',
beforeDelete: 'beforeDelete', beforeDelete: 'beforeDelete',
@@ -41,6 +42,11 @@ function validateClassNameForTriggers(className, type) {
// TODO: Allow proper documented way of using nested increment ops // TODO: Allow proper documented way of using nested increment ops
throw 'Only afterSave is allowed on _PushStatus'; 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; return className;
} }