feat: Improve authentication adapter interface to support multi-factor authentication (MFA), authentication challenges, and provide a more powerful interface for writing custom authentication adapters (#8156)
This commit is contained in:
313
src/RestWrite.js
313
src/RestWrite.js
@@ -16,7 +16,6 @@ const util = require('util');
|
||||
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
|
||||
@@ -114,6 +113,9 @@ RestWrite.prototype.execute = function () {
|
||||
.then(() => {
|
||||
return this.runBeforeSaveTrigger();
|
||||
})
|
||||
.then(() => {
|
||||
return this.ensureUniqueAuthDataId();
|
||||
})
|
||||
.then(() => {
|
||||
return this.deleteEmailResetTokenIfNeeded();
|
||||
})
|
||||
@@ -149,6 +151,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;
|
||||
});
|
||||
};
|
||||
@@ -375,7 +383,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');
|
||||
}
|
||||
@@ -385,10 +397,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
|
||||
@@ -398,15 +410,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);
|
||||
}
|
||||
}
|
||||
@@ -416,55 +427,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;
|
||||
@@ -478,106 +440,161 @@ RestWrite.prototype.filteredObjectsByACL = function (objects) {
|
||||
});
|
||||
};
|
||||
|
||||
RestWrite.prototype.handleAuthData = function (authData) {
|
||||
let results;
|
||||
return this.findUsersWithAuthData(authData).then(async r => {
|
||||
results = this.filteredObjectsByACL(r);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
if (results.length == 1) {
|
||||
this.storage['authProvider'] = Object.keys(authData).join(',');
|
||||
// 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 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;
|
||||
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;
|
||||
// Prevent validating if no mutated data detected on update
|
||||
if (!hasMutatedAuthData && isCurrentUserLoggedOrMaster) {
|
||||
return;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
// 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 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];
|
||||
});
|
||||
|
||||
// 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 },
|
||||
{}
|
||||
);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
@@ -848,7 +865,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
|
||||
) {
|
||||
@@ -865,15 +882,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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user