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:
dblythy
2022-11-11 03:35:39 +11:00
committed by GitHub
parent 4eb5f28b04
commit 5bbf9cade9
20 changed files with 2391 additions and 264 deletions

View File

@@ -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 | Login with expired 3rd party authentication token defaults to `false` | [#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."

View File

@@ -256,6 +256,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 +382,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 +405,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 +433,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();
},
@@ -541,6 +591,7 @@ describe('AuthenticationProviders', function () {
});
it('can depreciate', async () => {
await reconfigureServer();
const Deprecator = require('../lib/Deprecator/Deprecator');
const spy = spyOn(Deprecator, 'logRuntimeDeprecation').and.callFake(() => {});
const provider = getMockMyOauthProvider();

File diff suppressed because it is too large Load Diff

View File

@@ -942,8 +942,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 +7026,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 +7097,7 @@ describe('ParseGraphQLServer', () => {
sessionToken
user {
someField
authDataResponse
aPointer {
id
username
@@ -7059,6 +7113,11 @@ describe('ParseGraphQLServer', () => {
fields: {
username: 'user1',
password: 'user1',
authData: {
challengeAdapter: {
id: 'challengeAdapter',
},
},
aPointer: {
createAndLink: {
username: 'user2',
@@ -7078,6 +7137,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 +7148,7 @@ describe('ParseGraphQLServer', () => {
parseServer = await global.reconfigureServer({
publicServerURL: 'http://localhost:13377/parse',
auth: {
challengeAdapter,
myAuth: {
module: global.mockCustomAuthenticator('parse', 'graphql'),
},
@@ -7105,6 +7168,7 @@ describe('ParseGraphQLServer', () => {
sessionToken
user {
someField
authDataResponse
aPointer {
id
username
@@ -7118,6 +7182,7 @@ describe('ParseGraphQLServer', () => {
input: {
clientMutationId,
authData: {
challengeAdapter: { id: 'challengeAdapter' },
myAuth: {
id: 'parse',
password: 'graphql',
@@ -7143,9 +7208,92 @@ 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(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 +7310,7 @@ describe('ParseGraphQLServer', () => {
viewer {
sessionToken
user {
authDataResponse
someField
}
}
@@ -7173,6 +7322,7 @@ describe('ParseGraphQLServer', () => {
clientMutationId,
username: 'user1',
password: 'user1',
authData: { challengeAdapter: { token: true } },
},
},
});
@@ -7181,6 +7331,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 () => {

View File

@@ -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();

View File

@@ -1,20 +1,96 @@
/*eslint no-unused-vars: "off"*/
/**
* @interface ParseAuthResponse
* @property {Boolean} [doNotSave] If true, Parse Server will 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 adapter options
* @param {Parse.Cloud.TriggerRequest} request
* @returns {(Promise<undefined|void>|void|undefined)} resolves or returns if the applicationId is valid
*/
validateAppId(appIds, authData, options) {
validateAppId(appIds, authData, options, request) {
return Promise.resolve({});
}
/*
@param authData: the client provided authData
@param options: additional options
/**
* 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 {Parse.Cloud.TriggerRequest} request
* @param {Object} options additional adapter options
* @returns {Promise<ParseAuthResponse|void|undefined>}
*/
validateAuthData(authData, options) {
validateAuthData(authData, request, options) {
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 {Parse.Cloud.TriggerRequest} request
* @param {Object} options additional adapter options
* @returns {Promise<ParseAuthResponse|void|undefined>}
*/
validateSetUp(authData, req, options) {
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 {Parse.Cloud.TriggerRequest} request
* @param {Object} options additional adapter options
* @returns {Promise<ParseAuthResponse|void|undefined>}
*/
validateLogin(authData, req, options) {
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 adapter options
* @param {Parse.Cloud.TriggerRequest} request
* @returns {Promise<ParseAuthResponse|void|undefined>}
*/
validateUpdate(authData, req, options) {
return Promise.resolve({});
}
/**
* 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 adapter options
* @param {Parse.Cloud.TriggerRequest} request
* @returns {Promise<Object>} A promise that resolves, resolved value will be added to challenge response under challenge key
*/
challenge(challengeData, authData, options, request) {
return Promise.resolve({});
}
}

View File

@@ -1,4 +1,5 @@
import loadAdapter from '../AdapterLoader';
import Parse from 'parse/node';
const apple = require('./apple');
const gcenter = require('./gcenter');
@@ -61,19 +62,83 @@ 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));
}
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);
}
if (
typeof adapter.validateSetUp !== 'function' ||
typeof adapter.validateLogin !== 'function' ||
typeof adapter.validateUpdate !== 'function'
) {
throw new Parse.Error(
Parse.Error.OTHER_CAUSE,
'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate'
);
}
// 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;
}
if (isLoggedIn) {
// User is updating their authData
if (hasAuthDataConfigured) {
return {
method: 'validateUpdate',
validator: () => adapter.validateUpdate(authData, options, requestObject),
};
}
return Promise.resolve();
});
// Set up if the user does not have the provider configured
return {
method: 'validateSetUp',
validator: () => adapter.validateSetUp(authData, options, requestObject),
};
}
// Not logged in and authData is configured on the user
if (hasAuthDataConfigured) {
return {
method: 'validateLogin',
validator: () => adapter.validateLogin(authData, options, requestObject),
};
}
// 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 {
method: 'validateSetUp',
validator: () => adapter.validateSetUp(authData, options, requestObject),
};
};
}
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 +148,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 +160,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 +176,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 +187,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({

View File

@@ -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) {

View File

@@ -1,5 +1,8 @@
const RestQuery = require('./RestQuery');
const Parse = require('parse/node');
import { isDeepStrictEqual } from 'util';
import { getRequestObject, resolveError } from './triggers';
import Deprecator from './Deprecator/Deprecator';
import { logger } from './logger';
// An Auth object tells you who is requesting something and whether
// the master key was used.
@@ -83,7 +86,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 +128,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 +173,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 +267,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 +313,183 @@ 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
const acc = { authData: {}, authDataResponse: {} };
const authKeys = Object.keys(authData).sort();
for (const provider of authKeys) {
let method = '';
try {
if (authData[provider] === null) {
acc.authData[provider] = null;
continue;
}
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.'
);
}
let validationResult = await validator(authData[provider], req, user, requestObject);
method = validationResult && validationResult.method;
requestObject.triggerName = method;
if (validationResult && validationResult.validator) {
validationResult = await validationResult.validator();
}
if (!validationResult) {
acc.authData[provider] = authData[provider];
continue;
}
if (!Object.keys(validationResult).length) {
acc.authData[provider] = authData[provider];
continue;
}
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];
}
} catch (err) {
const e = resolveError(err, {
code: Parse.Error.SCRIPT_FAILED,
message: 'Auth failed. Unknown error.',
});
const userString =
req.auth && req.auth.user ? req.auth.user.id : req.data.objectId || undefined;
logger.error(
`Failed running auth step ${method} for ${provider} for user ${userString} with Error: ` +
JSON.stringify(e),
{
authenticationStep: method,
error: e,
user: userString,
provider,
}
);
throw e;
}
}
return acc;
};
module.exports = {
Auth,
master,
@@ -314,4 +497,8 @@ module.exports = {
readOnly,
getAuthForSessionToken,
getAuthForLegacySessionToken,
findUsersWithAuthData,
hasMutatedAuthData,
checkIfUserHasProvidedConfiguredProvidersForLogin,
handleAuthDataValidation,
};

View File

@@ -81,6 +81,7 @@ export class Config {
enforcePrivateUsers,
schema,
requestKeywordDenylist,
allowExpiredAuthDataToken,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -120,6 +121,7 @@ export class Config {
this.validateSecurityOptions(security);
this.validateSchemaOptions(schema);
this.validateEnforcePrivateUsers(enforcePrivateUsers);
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
this.validateRequestKeywordDenylist(requestKeywordDenylist);
}
@@ -137,6 +139,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.';

View File

@@ -24,4 +24,5 @@ module.exports = [
},
{ optionKey: 'enforcePrivateUsers', changeNewDefault: 'true' },
{ optionKey: 'allowClientClassCreation', changeNewDefault: 'false' },
{ optionKey: 'allowExpiredAuthDataToken', changeNewDefault: 'false' },
];

View File

@@ -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) => {

View File

@@ -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 };

View File

@@ -68,6 +68,13 @@ module.exports.ParseServerOptions = {
action: parsers.booleanParser,
default: false,
},
allowExpiredAuthDataToken: {
env: 'PARSE_SERVER_ALLOW_EXPIRED_AUTH_DATA_TOKEN',
help:
'Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `true`.',
action: parsers.booleanParser,
default: true,
},
allowHeaders: {
env: 'PARSE_SERVER_ALLOW_HEADERS',
help: 'Add headers to Access-Control-Allow-Headers',

View File

@@ -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 a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `true`.
* @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers
* @property {String} allowOrigin Sets the origin to Access-Control-Allow-Origin
* @property {Adapter<AnalyticsAdapter>} analyticsAdapter Adapter module for the analytics

View File

@@ -282,6 +282,9 @@ export interface ParseServerOptions {
/* Set to true if new users should be created without public read and write access.
:DEFAULT: false */
enforcePrivateUsers: ?boolean;
/* Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `true`.
: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[]);

View File

@@ -593,7 +593,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 => {
@@ -662,7 +662,7 @@ RestQuery.prototype.runFind = function (options = {}) {
.then(results => {
if (this.className === '_User' && !findOptions.explain) {
for (var result of results) {
cleanResultAuthData(result);
this.cleanResultAuthData(result);
}
}

View File

@@ -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,
});

View File

@@ -7,9 +7,15 @@ 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,
resolveError,
} from '../triggers';
import { promiseEnsureIdempotency } from '../middlewares';
import RestWrite from '../RestWrite';
import { logger } from '../logger';
export class UsersRouter extends ClassesRouter {
className() {
@@ -174,7 +180,6 @@ export class UsersRouter extends ClassesRouter {
// Remove hidden properties.
UsersRouter.removeHiddenProperties(user);
return { response: user };
}
});
@@ -182,6 +187,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 +257,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 +289,10 @@ export class UsersRouter extends ClassesRouter {
req.config
);
if (authDataResponse) {
user.authDataResponse = authDataResponse;
}
return { response: user };
}
@@ -453,6 +496,127 @@ 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.OBJECT_NOT_FOUND, '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);
const validatorResponse = await validator(authData[provider], req, parseUser, request);
if (validatorResponse && validatorResponse.validator) {
await validatorResponse.validator();
}
} catch (e) {
// Rewrite the error to avoid guess id attack
logger.error(e);
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, '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;
}
const acc = {};
// Execute challenge step-by-step with consistent order for better error feedback
// and to avoid to trigger others challenges if one of them fails
for (const provider of Object.keys(challengeData).sort()) {
try {
const authAdapter = req.config.authDataManager.getValidatorForProvider(provider);
if (!authAdapter) {
continue;
}
const {
adapter: { challenge },
} = authAdapter;
if (typeof challenge === 'function') {
const providerChallengeResponse = await challenge(
challengeData[provider],
authData && authData[provider],
req.config.auth[provider],
request
);
acc[provider] = providerChallengeResponse || true;
}
} catch (err) {
const e = resolveError(err, {
code: Parse.Error.SCRIPT_FAILED,
message: 'Challenge failed. Unknown error.',
});
const userString = req.auth && req.auth.user ? req.auth.user.id : undefined;
logger.error(
`Failed running auth step challenge for ${provider} for user ${userString} with Error: ` +
JSON.stringify(e),
{
authenticationStep: 'challenge',
error: e,
user: userString,
provider,
}
);
throw e;
}
}
return { response: { challengeData: acc } };
}
mountRoutes() {
this.route('GET', '/users', req => {
return this.handleFind(req);
@@ -493,6 +657,9 @@ export class UsersRouter extends ClassesRouter {
this.route('GET', '/verifyPassword', req => {
return this.handleVerifyPassword(req);
});
this.route('POST', '/challenge', req => {
return this.handleChallenge(req);
});
}
}

View File

@@ -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.