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:
@@ -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 | - |
|
| 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 | - |
|
| 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 | - |
|
| 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_deprecation]: ## "The version and date of the deprecation."
|
||||||
[i_removal]: ## "The version and date of the planned removal."
|
[i_removal]: ## "The version and date of the planned removal."
|
||||||
|
|||||||
@@ -256,6 +256,49 @@ describe('AuthenticationProviders', function () {
|
|||||||
.catch(done.fail);
|
.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 () => {
|
it('unlink and link with custom provider', async () => {
|
||||||
const provider = getMockMyOauthProvider();
|
const provider = getMockMyOauthProvider();
|
||||||
Parse.User._registerAuthenticationProvider(provider);
|
Parse.User._registerAuthenticationProvider(provider);
|
||||||
@@ -339,10 +382,10 @@ describe('AuthenticationProviders', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
validateAuthenticationHandler(authenticationHandler);
|
validateAuthenticationHandler(authenticationHandler);
|
||||||
const validator = authenticationHandler.getValidatorForProvider('customAuthentication');
|
const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication');
|
||||||
validateValidator(validator);
|
validateValidator(validator);
|
||||||
|
|
||||||
validator(validAuthData).then(
|
validator(validAuthData, {}, {}).then(
|
||||||
() => {
|
() => {
|
||||||
expect(authDataSpy).toHaveBeenCalled();
|
expect(authDataSpy).toHaveBeenCalled();
|
||||||
// AppIds are not provided in the adapter, should not be called
|
// AppIds are not provided in the adapter, should not be called
|
||||||
@@ -362,12 +405,15 @@ describe('AuthenticationProviders', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
validateAuthenticationHandler(authenticationHandler);
|
validateAuthenticationHandler(authenticationHandler);
|
||||||
const validator = authenticationHandler.getValidatorForProvider('customAuthentication');
|
const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication');
|
||||||
validateValidator(validator);
|
validateValidator(validator);
|
||||||
|
validator(
|
||||||
validator({
|
{
|
||||||
token: 'my-token',
|
token: 'my-token',
|
||||||
}).then(
|
},
|
||||||
|
{},
|
||||||
|
{}
|
||||||
|
).then(
|
||||||
() => {
|
() => {
|
||||||
done();
|
done();
|
||||||
},
|
},
|
||||||
@@ -387,12 +433,16 @@ describe('AuthenticationProviders', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
validateAuthenticationHandler(authenticationHandler);
|
validateAuthenticationHandler(authenticationHandler);
|
||||||
const validator = authenticationHandler.getValidatorForProvider('customAuthentication');
|
const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication');
|
||||||
validateValidator(validator);
|
validateValidator(validator);
|
||||||
|
|
||||||
validator({
|
validator(
|
||||||
|
{
|
||||||
token: 'valid-token',
|
token: 'valid-token',
|
||||||
}).then(
|
},
|
||||||
|
{},
|
||||||
|
{}
|
||||||
|
).then(
|
||||||
() => {
|
() => {
|
||||||
done();
|
done();
|
||||||
},
|
},
|
||||||
@@ -541,6 +591,7 @@ describe('AuthenticationProviders', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('can depreciate', async () => {
|
it('can depreciate', async () => {
|
||||||
|
await reconfigureServer();
|
||||||
const Deprecator = require('../lib/Deprecator/Deprecator');
|
const Deprecator = require('../lib/Deprecator/Deprecator');
|
||||||
const spy = spyOn(Deprecator, 'logRuntimeDeprecation').and.callFake(() => {});
|
const spy = spyOn(Deprecator, 'logRuntimeDeprecation').and.callFake(() => {});
|
||||||
const provider = getMockMyOauthProvider();
|
const provider = getMockMyOauthProvider();
|
||||||
|
|||||||
1251
spec/AuthenticationAdaptersV2.spec.js
Normal file
1251
spec/AuthenticationAdaptersV2.spec.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -942,8 +942,7 @@ describe('ParseGraphQLServer', () => {
|
|||||||
).data['__type'].inputFields
|
).data['__type'].inputFields
|
||||||
.map(field => field.name)
|
.map(field => field.name)
|
||||||
.sort();
|
.sort();
|
||||||
|
expect(inputFields).toEqual(['authData', 'clientMutationId', 'password', 'username']);
|
||||||
expect(inputFields).toEqual(['clientMutationId', 'password', 'username']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have clientMutationId in log in mutation payload', async () => {
|
it('should have clientMutationId in log in mutation payload', async () => {
|
||||||
@@ -7027,7 +7026,61 @@ describe('ParseGraphQLServer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Users Mutations', () => {
|
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 () => {
|
it('should sign user up', async () => {
|
||||||
|
parseServer = await global.reconfigureServer({
|
||||||
|
publicServerURL: 'http://localhost:13377/parse',
|
||||||
|
auth: {
|
||||||
|
challengeAdapter,
|
||||||
|
},
|
||||||
|
});
|
||||||
const clientMutationId = uuidv4();
|
const clientMutationId = uuidv4();
|
||||||
const userSchema = new Parse.Schema('_User');
|
const userSchema = new Parse.Schema('_User');
|
||||||
userSchema.addString('someField');
|
userSchema.addString('someField');
|
||||||
@@ -7044,6 +7097,7 @@ describe('ParseGraphQLServer', () => {
|
|||||||
sessionToken
|
sessionToken
|
||||||
user {
|
user {
|
||||||
someField
|
someField
|
||||||
|
authDataResponse
|
||||||
aPointer {
|
aPointer {
|
||||||
id
|
id
|
||||||
username
|
username
|
||||||
@@ -7059,6 +7113,11 @@ describe('ParseGraphQLServer', () => {
|
|||||||
fields: {
|
fields: {
|
||||||
username: 'user1',
|
username: 'user1',
|
||||||
password: 'user1',
|
password: 'user1',
|
||||||
|
authData: {
|
||||||
|
challengeAdapter: {
|
||||||
|
id: 'challengeAdapter',
|
||||||
|
},
|
||||||
|
},
|
||||||
aPointer: {
|
aPointer: {
|
||||||
createAndLink: {
|
createAndLink: {
|
||||||
username: 'user2',
|
username: 'user2',
|
||||||
@@ -7078,6 +7137,9 @@ describe('ParseGraphQLServer', () => {
|
|||||||
expect(result.data.signUp.viewer.user.aPointer.id).toBeDefined();
|
expect(result.data.signUp.viewer.user.aPointer.id).toBeDefined();
|
||||||
expect(result.data.signUp.viewer.user.aPointer.username).toEqual('user2');
|
expect(result.data.signUp.viewer.user.aPointer.username).toEqual('user2');
|
||||||
expect(typeof result.data.signUp.viewer.sessionToken).toBe('string');
|
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 () => {
|
it('should login with user', async () => {
|
||||||
@@ -7086,6 +7148,7 @@ describe('ParseGraphQLServer', () => {
|
|||||||
parseServer = await global.reconfigureServer({
|
parseServer = await global.reconfigureServer({
|
||||||
publicServerURL: 'http://localhost:13377/parse',
|
publicServerURL: 'http://localhost:13377/parse',
|
||||||
auth: {
|
auth: {
|
||||||
|
challengeAdapter,
|
||||||
myAuth: {
|
myAuth: {
|
||||||
module: global.mockCustomAuthenticator('parse', 'graphql'),
|
module: global.mockCustomAuthenticator('parse', 'graphql'),
|
||||||
},
|
},
|
||||||
@@ -7105,6 +7168,7 @@ describe('ParseGraphQLServer', () => {
|
|||||||
sessionToken
|
sessionToken
|
||||||
user {
|
user {
|
||||||
someField
|
someField
|
||||||
|
authDataResponse
|
||||||
aPointer {
|
aPointer {
|
||||||
id
|
id
|
||||||
username
|
username
|
||||||
@@ -7118,6 +7182,7 @@ describe('ParseGraphQLServer', () => {
|
|||||||
input: {
|
input: {
|
||||||
clientMutationId,
|
clientMutationId,
|
||||||
authData: {
|
authData: {
|
||||||
|
challengeAdapter: { id: 'challengeAdapter' },
|
||||||
myAuth: {
|
myAuth: {
|
||||||
id: 'parse',
|
id: 'parse',
|
||||||
password: 'graphql',
|
password: 'graphql',
|
||||||
@@ -7143,9 +7208,92 @@ describe('ParseGraphQLServer', () => {
|
|||||||
expect(typeof result.data.logInWith.viewer.sessionToken).toBe('string');
|
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.id).toBeDefined();
|
||||||
expect(result.data.logInWith.viewer.user.aPointer.username).toEqual('user2');
|
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 () => {
|
it('should log the user in', async () => {
|
||||||
|
parseServer = await global.reconfigureServer({
|
||||||
|
publicServerURL: 'http://localhost:13377/parse',
|
||||||
|
auth: {
|
||||||
|
challengeAdapter,
|
||||||
|
},
|
||||||
|
});
|
||||||
const clientMutationId = uuidv4();
|
const clientMutationId = uuidv4();
|
||||||
const user = new Parse.User();
|
const user = new Parse.User();
|
||||||
user.setUsername('user1');
|
user.setUsername('user1');
|
||||||
@@ -7162,6 +7310,7 @@ describe('ParseGraphQLServer', () => {
|
|||||||
viewer {
|
viewer {
|
||||||
sessionToken
|
sessionToken
|
||||||
user {
|
user {
|
||||||
|
authDataResponse
|
||||||
someField
|
someField
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7173,6 +7322,7 @@ describe('ParseGraphQLServer', () => {
|
|||||||
clientMutationId,
|
clientMutationId,
|
||||||
username: 'user1',
|
username: 'user1',
|
||||||
password: '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.sessionToken).toBeDefined();
|
||||||
expect(result.data.logIn.viewer.user.someField).toEqual('someValue');
|
expect(result.data.logIn.viewer.user.someField).toEqual('someValue');
|
||||||
expect(typeof result.data.logIn.viewer.sessionToken).toBe('string');
|
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 () => {
|
it('should log the user out', async () => {
|
||||||
|
|||||||
@@ -13,6 +13,63 @@ const passwordCrypto = require('../lib/password');
|
|||||||
const Config = require('../lib/Config');
|
const Config = require('../lib/Config');
|
||||||
const cryptoUtils = require('../lib/cryptoUtils');
|
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', () => {
|
describe('Parse.User testing', () => {
|
||||||
it('user sign up class method', async done => {
|
it('user sign up class method', async done => {
|
||||||
const user = await Parse.User.signUp('asdf', 'zxcv');
|
const user = await Parse.User.signUp('asdf', 'zxcv');
|
||||||
@@ -1129,7 +1186,7 @@ describe('Parse.User testing', () => {
|
|||||||
this.synchronizedExpiration = authData.expiration_date;
|
this.synchronizedExpiration = authData.expiration_date;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
getAuthType: function () {
|
getAuthType() {
|
||||||
return 'facebook';
|
return 'facebook';
|
||||||
},
|
},
|
||||||
deauthenticate: function () {
|
deauthenticate: function () {
|
||||||
@@ -1158,7 +1215,7 @@ describe('Parse.User testing', () => {
|
|||||||
synchronizedAuthToken: null,
|
synchronizedAuthToken: null,
|
||||||
synchronizedExpiration: null,
|
synchronizedExpiration: null,
|
||||||
|
|
||||||
authenticate: function (options) {
|
authenticate(options) {
|
||||||
if (this.shouldError) {
|
if (this.shouldError) {
|
||||||
options.error(this, 'An error occurred');
|
options.error(this, 'An error occurred');
|
||||||
} else if (this.shouldCancel) {
|
} else if (this.shouldCancel) {
|
||||||
@@ -1167,7 +1224,7 @@ describe('Parse.User testing', () => {
|
|||||||
options.success(this, this.authData);
|
options.success(this, this.authData);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
restoreAuthentication: function (authData) {
|
restoreAuthentication(authData) {
|
||||||
if (!authData) {
|
if (!authData) {
|
||||||
this.synchronizedUserId = null;
|
this.synchronizedUserId = null;
|
||||||
this.synchronizedAuthToken = null;
|
this.synchronizedAuthToken = null;
|
||||||
@@ -1179,10 +1236,10 @@ describe('Parse.User testing', () => {
|
|||||||
this.synchronizedExpiration = authData.expiration_date;
|
this.synchronizedExpiration = authData.expiration_date;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
getAuthType: function () {
|
getAuthType() {
|
||||||
return 'myoauth';
|
return 'myoauth';
|
||||||
},
|
},
|
||||||
deauthenticate: function () {
|
deauthenticate() {
|
||||||
this.loggedOut = true;
|
this.loggedOut = true;
|
||||||
this.restoreAuthentication(null);
|
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 = {
|
const provider = {
|
||||||
authData: {
|
authData: {
|
||||||
id: '12345',
|
id: '12345',
|
||||||
@@ -1813,22 +1870,42 @@ describe('Parse.User testing', () => {
|
|||||||
};
|
};
|
||||||
defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token');
|
defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token');
|
||||||
Parse.User._registerAuthenticationProvider(provider);
|
Parse.User._registerAuthenticationProvider(provider);
|
||||||
Parse.User._logInWith('shortLivedAuth', {})
|
await Parse.User._logInWith('shortLivedAuth', {});
|
||||||
.then(() => {
|
|
||||||
// Simulate a remotely expired token (like a short lived one)
|
// Simulate a remotely expired token (like a short lived one)
|
||||||
// In this case, we want success as it was valid once.
|
// In this case, we want success as it was valid once.
|
||||||
// If the client needs an updated one, do lock the user out
|
// If the client needs an updated token, do lock the user out
|
||||||
defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken');
|
defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken');
|
||||||
return Parse.User._logInWith('shortLivedAuth', {});
|
await Parse.User._logInWith('shortLivedAuth', {});
|
||||||
})
|
});
|
||||||
.then(
|
|
||||||
() => {
|
it('should not allow login with expired authData token when allowExpiredAuthDataToken is set to false', async () => {
|
||||||
done();
|
await reconfigureServer({ allowExpiredAuthDataToken: false });
|
||||||
|
const provider = {
|
||||||
|
authData: {
|
||||||
|
id: '12345',
|
||||||
|
access_token: 'token',
|
||||||
},
|
},
|
||||||
err => {
|
restoreAuthentication() {
|
||||||
done.fail(err);
|
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 => {
|
it('should allow PUT request with stale auth Data', done => {
|
||||||
@@ -2260,37 +2337,14 @@ describe('Parse.User testing', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('anonymous users', () => {
|
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 () => {
|
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 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');
|
const username2 = user2.get('username');
|
||||||
|
|
||||||
expect(username1).not.toBeUndefined();
|
expect(username1).not.toBeUndefined();
|
||||||
|
|||||||
@@ -1,20 +1,96 @@
|
|||||||
/*eslint no-unused-vars: "off"*/
|
/*eslint no-unused-vars: "off"*/
|
||||||
export class AuthAdapter {
|
|
||||||
/*
|
/**
|
||||||
@param appIds: the specified app ids in the configuration
|
* @interface ParseAuthResponse
|
||||||
@param authData: the client provided authData
|
* @property {Boolean} [doNotSave] If true, Parse Server will not save provided authData.
|
||||||
@param options: additional options
|
* @property {Object} [response] If set, Parse Server will send the provided response to the client under authDataResponse
|
||||||
@returns a promise that resolves if the applicationId is valid
|
* @property {Object} [save] If set, Parse Server will save the object provided into this key, instead of client provided authData
|
||||||
*/
|
*/
|
||||||
validateAppId(appIds, authData, options) {
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
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, request) {
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/**
|
||||||
@param authData: the client provided authData
|
* Legacy usage, if provided it will be triggered when authData related to this provider is touched (signup/update/login)
|
||||||
@param options: additional options
|
* 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({});
|
return Promise.resolve({});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import loadAdapter from '../AdapterLoader';
|
import loadAdapter from '../AdapterLoader';
|
||||||
|
import Parse from 'parse/node';
|
||||||
|
|
||||||
const apple = require('./apple');
|
const apple = require('./apple');
|
||||||
const gcenter = require('./gcenter');
|
const gcenter = require('./gcenter');
|
||||||
@@ -61,19 +62,83 @@ const providers = {
|
|||||||
ldap,
|
ldap,
|
||||||
};
|
};
|
||||||
|
|
||||||
function authDataValidator(adapter, appIds, options) {
|
// Indexed auth policies
|
||||||
return function (authData) {
|
const authAdapterPolicies = {
|
||||||
return adapter.validateAuthData(authData, options).then(() => {
|
default: true,
|
||||||
if (appIds) {
|
solo: true,
|
||||||
return adapter.validateAppId(appIds, authData, options);
|
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));
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 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) {
|
function loadAuthAdapter(provider, authOptions) {
|
||||||
|
// providers are auth providers implemented by default
|
||||||
let defaultAdapter = providers[provider];
|
let defaultAdapter = providers[provider];
|
||||||
|
// authOptions can contain complete custom auth adapters or
|
||||||
|
// a default auth adapter like Facebook
|
||||||
const providerOptions = authOptions[provider];
|
const providerOptions = authOptions[provider];
|
||||||
if (
|
if (
|
||||||
providerOptions &&
|
providerOptions &&
|
||||||
@@ -83,6 +148,7 @@ function loadAuthAdapter(provider, authOptions) {
|
|||||||
defaultAdapter = oauth2;
|
defaultAdapter = oauth2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default provider not found and a custom auth provider was not provided
|
||||||
if (!defaultAdapter && !providerOptions) {
|
if (!defaultAdapter && !providerOptions) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,7 +160,15 @@ function loadAuthAdapter(provider, authOptions) {
|
|||||||
if (providerOptions) {
|
if (providerOptions) {
|
||||||
const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions);
|
const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions);
|
||||||
if (optionalAdapter) {
|
if (optionalAdapter) {
|
||||||
['validateAuthData', 'validateAppId'].forEach(key => {
|
[
|
||||||
|
'validateAuthData',
|
||||||
|
'validateAppId',
|
||||||
|
'validateSetUp',
|
||||||
|
'validateLogin',
|
||||||
|
'validateUpdate',
|
||||||
|
'challenge',
|
||||||
|
'policy',
|
||||||
|
].forEach(key => {
|
||||||
if (optionalAdapter[key]) {
|
if (optionalAdapter[key]) {
|
||||||
adapter[key] = 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 };
|
return { adapter, appIds, providerOptions };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,12 +187,12 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) {
|
|||||||
// To handle the test cases on configuration
|
// To handle the test cases on configuration
|
||||||
const getValidatorForProvider = function (provider) {
|
const getValidatorForProvider = function (provider) {
|
||||||
if (provider === 'anonymous' && !_enableAnonymousUsers) {
|
if (provider === 'anonymous' && !_enableAnonymousUsers) {
|
||||||
return;
|
return { validator: undefined };
|
||||||
}
|
}
|
||||||
|
const authAdapter = loadAuthAdapter(provider, authOptions);
|
||||||
const { adapter, appIds, providerOptions } = loadAuthAdapter(provider, authOptions);
|
if (!authAdapter) return;
|
||||||
|
const { adapter, appIds, providerOptions } = authAdapter;
|
||||||
return authDataValidator(adapter, appIds, providerOptions);
|
return { validator: authDataValidator(provider, adapter, appIds, providerOptions), adapter };
|
||||||
};
|
};
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -289,7 +289,6 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/);
|
const authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/);
|
||||||
if (authDataMatch) {
|
if (authDataMatch) {
|
||||||
// TODO: Handle querying by _auth_data_provider, authData is stored in authData field
|
// TODO: Handle querying by _auth_data_provider, authData is stored in authData field
|
||||||
@@ -1322,12 +1321,17 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/);
|
var authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/);
|
||||||
|
const authDataAlreadyExists = !!object.authData;
|
||||||
if (authDataMatch) {
|
if (authDataMatch) {
|
||||||
var provider = authDataMatch[1];
|
var provider = authDataMatch[1];
|
||||||
object['authData'] = object['authData'] || {};
|
object['authData'] = object['authData'] || {};
|
||||||
object['authData'][provider] = object[fieldName];
|
object['authData'][provider] = object[fieldName];
|
||||||
delete object[fieldName];
|
delete object[fieldName];
|
||||||
fieldName = 'authData';
|
fieldName = 'authData';
|
||||||
|
// Avoid adding authData multiple times to the query
|
||||||
|
if (authDataAlreadyExists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
columnsArray.push(fieldName);
|
columnsArray.push(fieldName);
|
||||||
@@ -1807,7 +1811,6 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
|||||||
caseInsensitive,
|
caseInsensitive,
|
||||||
});
|
});
|
||||||
values.push(...where.values);
|
values.push(...where.values);
|
||||||
|
|
||||||
const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : '';
|
const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : '';
|
||||||
const limitPattern = hasLimit ? `LIMIT $${values.length + 1}` : '';
|
const limitPattern = hasLimit ? `LIMIT $${values.length + 1}` : '';
|
||||||
if (hasLimit) {
|
if (hasLimit) {
|
||||||
|
|||||||
191
src/Auth.js
191
src/Auth.js
@@ -1,5 +1,8 @@
|
|||||||
const RestQuery = require('./RestQuery');
|
|
||||||
const Parse = require('parse/node');
|
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
|
// An Auth object tells you who is requesting something and whether
|
||||||
// the master key was used.
|
// the master key was used.
|
||||||
@@ -83,7 +86,7 @@ const getAuthForSessionToken = async function ({
|
|||||||
limit: 1,
|
limit: 1,
|
||||||
include: 'user',
|
include: 'user',
|
||||||
};
|
};
|
||||||
|
const RestQuery = require('./RestQuery');
|
||||||
const query = new RestQuery(config, master(config), '_Session', { sessionToken }, restOptions);
|
const query = new RestQuery(config, master(config), '_Session', { sessionToken }, restOptions);
|
||||||
results = (await query.execute()).results;
|
results = (await query.execute()).results;
|
||||||
} else {
|
} else {
|
||||||
@@ -125,6 +128,7 @@ var getAuthForLegacySessionToken = function ({ config, sessionToken, installatio
|
|||||||
var restOptions = {
|
var restOptions = {
|
||||||
limit: 1,
|
limit: 1,
|
||||||
};
|
};
|
||||||
|
const RestQuery = require('./RestQuery');
|
||||||
var query = new RestQuery(config, master(config), '_User', { sessionToken }, restOptions);
|
var query = new RestQuery(config, master(config), '_User', { sessionToken }, restOptions);
|
||||||
return query.execute().then(response => {
|
return query.execute().then(response => {
|
||||||
var results = response.results;
|
var results = response.results;
|
||||||
@@ -169,6 +173,7 @@ Auth.prototype.getRolesForUser = async function () {
|
|||||||
objectId: this.user.id,
|
objectId: this.user.id,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const RestQuery = require('./RestQuery');
|
||||||
await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result =>
|
await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result =>
|
||||||
results.push(result)
|
results.push(result)
|
||||||
);
|
);
|
||||||
@@ -262,6 +267,7 @@ Auth.prototype.getRolesByIds = async function (ins) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const restWhere = { roles: { $in: roles } };
|
const restWhere = { roles: { $in: roles } };
|
||||||
|
const RestQuery = require('./RestQuery');
|
||||||
await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result =>
|
await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result =>
|
||||||
results.push(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 = {
|
module.exports = {
|
||||||
Auth,
|
Auth,
|
||||||
master,
|
master,
|
||||||
@@ -314,4 +497,8 @@ module.exports = {
|
|||||||
readOnly,
|
readOnly,
|
||||||
getAuthForSessionToken,
|
getAuthForSessionToken,
|
||||||
getAuthForLegacySessionToken,
|
getAuthForLegacySessionToken,
|
||||||
|
findUsersWithAuthData,
|
||||||
|
hasMutatedAuthData,
|
||||||
|
checkIfUserHasProvidedConfiguredProvidersForLogin,
|
||||||
|
handleAuthDataValidation,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export class Config {
|
|||||||
enforcePrivateUsers,
|
enforcePrivateUsers,
|
||||||
schema,
|
schema,
|
||||||
requestKeywordDenylist,
|
requestKeywordDenylist,
|
||||||
|
allowExpiredAuthDataToken,
|
||||||
}) {
|
}) {
|
||||||
if (masterKey === readOnlyMasterKey) {
|
if (masterKey === readOnlyMasterKey) {
|
||||||
throw new Error('masterKey and readOnlyMasterKey should be different');
|
throw new Error('masterKey and readOnlyMasterKey should be different');
|
||||||
@@ -120,6 +121,7 @@ export class Config {
|
|||||||
this.validateSecurityOptions(security);
|
this.validateSecurityOptions(security);
|
||||||
this.validateSchemaOptions(schema);
|
this.validateSchemaOptions(schema);
|
||||||
this.validateEnforcePrivateUsers(enforcePrivateUsers);
|
this.validateEnforcePrivateUsers(enforcePrivateUsers);
|
||||||
|
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
|
||||||
this.validateRequestKeywordDenylist(requestKeywordDenylist);
|
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) {
|
static validateSecurityOptions(security) {
|
||||||
if (Object.prototype.toString.call(security) !== '[object Object]') {
|
if (Object.prototype.toString.call(security) !== '[object Object]') {
|
||||||
throw 'Parse Server option security must be an object.';
|
throw 'Parse Server option security must be an object.';
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ module.exports = [
|
|||||||
},
|
},
|
||||||
{ optionKey: 'enforcePrivateUsers', changeNewDefault: 'true' },
|
{ optionKey: 'enforcePrivateUsers', changeNewDefault: 'true' },
|
||||||
{ optionKey: 'allowClientClassCreation', changeNewDefault: 'false' },
|
{ optionKey: 'allowClientClassCreation', changeNewDefault: 'false' },
|
||||||
|
{ optionKey: 'allowExpiredAuthDataToken', changeNewDefault: 'false' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable indent */
|
||||||
import {
|
import {
|
||||||
GraphQLID,
|
GraphQLID,
|
||||||
GraphQLObjectType,
|
GraphQLObjectType,
|
||||||
@@ -140,11 +141,7 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla
|
|||||||
...fields,
|
...fields,
|
||||||
[field]: {
|
[field]: {
|
||||||
description: `This is the object ${field}.`,
|
description: `This is the object ${field}.`,
|
||||||
type:
|
type: parseClass.fields[field].required ? new GraphQLNonNull(type) : type,
|
||||||
(className === '_User' && (field === 'username' || field === 'password')) ||
|
|
||||||
parseClass.fields[field].required
|
|
||||||
? new GraphQLNonNull(type)
|
|
||||||
: type,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -352,6 +349,14 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla
|
|||||||
const parseObjectFields = {
|
const parseObjectFields = {
|
||||||
id: globalIdField(className, obj => obj.objectId),
|
id: globalIdField(className, obj => obj.objectId),
|
||||||
...defaultGraphQLTypes.PARSE_OBJECT_FIELDS,
|
...defaultGraphQLTypes.PARSE_OBJECT_FIELDS,
|
||||||
|
...(className === '_User'
|
||||||
|
? {
|
||||||
|
authDataResponse: {
|
||||||
|
description: `auth provider response when triggered on signUp/logIn.`,
|
||||||
|
type: defaultGraphQLTypes.OBJECT,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
const outputFields = () => {
|
const outputFields = () => {
|
||||||
return classOutputFields.reduce((fields, field) => {
|
return classOutputFields.reduce((fields, field) => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const load = parseGraphQLSchema => {
|
|||||||
req: { config, auth, info },
|
req: { config, auth, info },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { sessionToken, objectId } = await objectsMutations.createObject(
|
const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject(
|
||||||
'_User',
|
'_User',
|
||||||
parseFields,
|
parseFields,
|
||||||
config,
|
config,
|
||||||
@@ -50,9 +50,15 @@ const load = parseGraphQLSchema => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
context.info.sessionToken = sessionToken;
|
context.info.sessionToken = sessionToken;
|
||||||
|
const viewer = await getUserFromSessionToken(
|
||||||
|
context,
|
||||||
|
mutationInfo,
|
||||||
|
'viewer.user.',
|
||||||
|
objectId
|
||||||
|
);
|
||||||
|
if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse;
|
||||||
return {
|
return {
|
||||||
viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId),
|
viewer,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
parseGraphQLSchema.handleError(e);
|
parseGraphQLSchema.handleError(e);
|
||||||
@@ -111,7 +117,7 @@ const load = parseGraphQLSchema => {
|
|||||||
req: { config, auth, info },
|
req: { config, auth, info },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { sessionToken, objectId } = await objectsMutations.createObject(
|
const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject(
|
||||||
'_User',
|
'_User',
|
||||||
{ ...parseFields, authData },
|
{ ...parseFields, authData },
|
||||||
config,
|
config,
|
||||||
@@ -120,9 +126,15 @@ const load = parseGraphQLSchema => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
context.info.sessionToken = sessionToken;
|
context.info.sessionToken = sessionToken;
|
||||||
|
const viewer = await getUserFromSessionToken(
|
||||||
|
context,
|
||||||
|
mutationInfo,
|
||||||
|
'viewer.user.',
|
||||||
|
objectId
|
||||||
|
);
|
||||||
|
if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse;
|
||||||
return {
|
return {
|
||||||
viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId),
|
viewer,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
parseGraphQLSchema.handleError(e);
|
parseGraphQLSchema.handleError(e);
|
||||||
@@ -146,6 +158,10 @@ const load = parseGraphQLSchema => {
|
|||||||
description: 'This is the password used to log in the user.',
|
description: 'This is the password used to log in the user.',
|
||||||
type: new GraphQLNonNull(GraphQLString),
|
type: new GraphQLNonNull(GraphQLString),
|
||||||
},
|
},
|
||||||
|
authData: {
|
||||||
|
description: 'Auth data payload, needed if some required auth adapters are configured.',
|
||||||
|
type: OBJECT,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
outputFields: {
|
outputFields: {
|
||||||
viewer: {
|
viewer: {
|
||||||
@@ -155,14 +171,15 @@ const load = parseGraphQLSchema => {
|
|||||||
},
|
},
|
||||||
mutateAndGetPayload: async (args, context, mutationInfo) => {
|
mutateAndGetPayload: async (args, context, mutationInfo) => {
|
||||||
try {
|
try {
|
||||||
const { username, password } = deepcopy(args);
|
const { username, password, authData } = deepcopy(args);
|
||||||
const { config, auth, info } = context;
|
const { config, auth, info } = context;
|
||||||
|
|
||||||
const { sessionToken, objectId } = (
|
const { sessionToken, objectId, authDataResponse } = (
|
||||||
await usersRouter.handleLogIn({
|
await usersRouter.handleLogIn({
|
||||||
body: {
|
body: {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
authData,
|
||||||
},
|
},
|
||||||
query: {},
|
query: {},
|
||||||
config,
|
config,
|
||||||
@@ -173,8 +190,15 @@ const load = parseGraphQLSchema => {
|
|||||||
|
|
||||||
context.info.sessionToken = sessionToken;
|
context.info.sessionToken = sessionToken;
|
||||||
|
|
||||||
|
const viewer = await getUserFromSessionToken(
|
||||||
|
context,
|
||||||
|
mutationInfo,
|
||||||
|
'viewer.user.',
|
||||||
|
objectId
|
||||||
|
);
|
||||||
|
if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse;
|
||||||
return {
|
return {
|
||||||
viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId),
|
viewer,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
parseGraphQLSchema.handleError(e);
|
parseGraphQLSchema.handleError(e);
|
||||||
@@ -355,6 +379,57 @@ const load = parseGraphQLSchema => {
|
|||||||
true,
|
true,
|
||||||
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 };
|
export { load };
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ module.exports.ParseServerOptions = {
|
|||||||
action: parsers.booleanParser,
|
action: parsers.booleanParser,
|
||||||
default: false,
|
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: {
|
allowHeaders: {
|
||||||
env: 'PARSE_SERVER_ALLOW_HEADERS',
|
env: 'PARSE_SERVER_ALLOW_HEADERS',
|
||||||
help: 'Add headers to Access-Control-Allow-Headers',
|
help: 'Add headers to Access-Control-Allow-Headers',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
* @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.
|
* @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} allowClientClassCreation Enable (or disable) client class creation, defaults to true
|
||||||
* @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId
|
* @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[]} allowHeaders Add headers to Access-Control-Allow-Headers
|
||||||
* @property {String} allowOrigin Sets the origin to Access-Control-Allow-Origin
|
* @property {String} allowOrigin Sets the origin to Access-Control-Allow-Origin
|
||||||
* @property {Adapter<AnalyticsAdapter>} analyticsAdapter Adapter module for the analytics
|
* @property {Adapter<AnalyticsAdapter>} analyticsAdapter Adapter module for the analytics
|
||||||
|
|||||||
@@ -282,6 +282,9 @@ export interface ParseServerOptions {
|
|||||||
/* Set to true if new users should be created without public read and write access.
|
/* Set to true if new users should be created without public read and write access.
|
||||||
:DEFAULT: false */
|
:DEFAULT: false */
|
||||||
enforcePrivateUsers: ?boolean;
|
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.
|
/* 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__"}] */
|
:DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */
|
||||||
requestKeywordDenylist: ?(RequestKeywordDenylist[]);
|
requestKeywordDenylist: ?(RequestKeywordDenylist[]);
|
||||||
|
|||||||
@@ -593,7 +593,7 @@ RestQuery.prototype.replaceDontSelect = function () {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanResultAuthData = function (result) {
|
RestQuery.prototype.cleanResultAuthData = function (result) {
|
||||||
delete result.password;
|
delete result.password;
|
||||||
if (result.authData) {
|
if (result.authData) {
|
||||||
Object.keys(result.authData).forEach(provider => {
|
Object.keys(result.authData).forEach(provider => {
|
||||||
@@ -662,7 +662,7 @@ RestQuery.prototype.runFind = function (options = {}) {
|
|||||||
.then(results => {
|
.then(results => {
|
||||||
if (this.className === '_User' && !findOptions.explain) {
|
if (this.className === '_User' && !findOptions.explain) {
|
||||||
for (var result of results) {
|
for (var result of results) {
|
||||||
cleanResultAuthData(result);
|
this.cleanResultAuthData(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
251
src/RestWrite.js
251
src/RestWrite.js
@@ -16,7 +16,6 @@ const util = require('util');
|
|||||||
import RestQuery from './RestQuery';
|
import RestQuery from './RestQuery';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import logger from './logger';
|
import logger from './logger';
|
||||||
import Deprecator from './Deprecator/Deprecator';
|
|
||||||
import { requiredColumns } from './Controllers/SchemaController';
|
import { requiredColumns } from './Controllers/SchemaController';
|
||||||
|
|
||||||
// query and data are both provided in REST API format. So data
|
// query and data are both provided in REST API format. So data
|
||||||
@@ -114,6 +113,9 @@ RestWrite.prototype.execute = function () {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
return this.runBeforeSaveTrigger();
|
return this.runBeforeSaveTrigger();
|
||||||
})
|
})
|
||||||
|
.then(() => {
|
||||||
|
return this.ensureUniqueAuthDataId();
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
return this.deleteEmailResetTokenIfNeeded();
|
return this.deleteEmailResetTokenIfNeeded();
|
||||||
})
|
})
|
||||||
@@ -149,6 +151,12 @@ RestWrite.prototype.execute = function () {
|
|||||||
return this.cleanUserAuthData();
|
return this.cleanUserAuthData();
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
// Append the authDataResponse if exists
|
||||||
|
if (this.authDataResponse) {
|
||||||
|
if (this.response && this.response.response) {
|
||||||
|
this.response.response.authDataResponse = this.authDataResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
return this.response;
|
return this.response;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -375,7 +383,11 @@ RestWrite.prototype.validateAuthData = function () {
|
|||||||
return;
|
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)) {
|
if (typeof this.data.username !== 'string' || _.isEmpty(this.data.username)) {
|
||||||
throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username');
|
throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username');
|
||||||
}
|
}
|
||||||
@@ -385,10 +397,10 @@ RestWrite.prototype.validateAuthData = function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(this.data.authData && !Object.keys(this.data.authData).length) ||
|
(authData && !Object.keys(authData).length) ||
|
||||||
!Object.prototype.hasOwnProperty.call(this.data, 'authData')
|
!Object.prototype.hasOwnProperty.call(this.data, 'authData')
|
||||||
) {
|
) {
|
||||||
// Handle saving authData to {} or if authData doesn't exist
|
// Nothing to validate here
|
||||||
return;
|
return;
|
||||||
} else if (Object.prototype.hasOwnProperty.call(this.data, 'authData') && !this.data.authData) {
|
} else if (Object.prototype.hasOwnProperty.call(this.data, 'authData') && !this.data.authData) {
|
||||||
// Handle saving authData to null
|
// Handle saving authData to null
|
||||||
@@ -398,15 +410,14 @@ RestWrite.prototype.validateAuthData = function () {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var authData = this.data.authData;
|
|
||||||
var providers = Object.keys(authData);
|
var providers = Object.keys(authData);
|
||||||
if (providers.length > 0) {
|
if (providers.length > 0) {
|
||||||
const canHandleAuthData = providers.reduce((canHandle, provider) => {
|
const canHandleAuthData = providers.some(provider => {
|
||||||
var providerAuthData = authData[provider];
|
var providerAuthData = authData[provider];
|
||||||
var hasToken = providerAuthData && providerAuthData.id;
|
var hasToken = providerAuthData && providerAuthData.id;
|
||||||
return canHandle && (hasToken || providerAuthData == null);
|
return hasToken || providerAuthData === null;
|
||||||
}, true);
|
});
|
||||||
if (canHandleAuthData) {
|
if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) {
|
||||||
return this.handleAuthData(authData);
|
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) {
|
RestWrite.prototype.filteredObjectsByACL = function (objects) {
|
||||||
if (this.auth.isMaster) {
|
if (this.auth.isMaster) {
|
||||||
return objects;
|
return objects;
|
||||||
@@ -478,31 +440,86 @@ RestWrite.prototype.filteredObjectsByACL = function (objects) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
RestWrite.prototype.handleAuthData = function (authData) {
|
RestWrite.prototype.getUserId = function () {
|
||||||
let results;
|
if (this.query && this.query.objectId && this.className === '_User') {
|
||||||
return this.findUsersWithAuthData(authData).then(async r => {
|
return this.query.objectId;
|
||||||
results = this.filteredObjectsByACL(r);
|
|
||||||
|
|
||||||
if (results.length == 1) {
|
|
||||||
this.storage['authProvider'] = Object.keys(authData).join(',');
|
|
||||||
|
|
||||||
const userResult = results[0];
|
|
||||||
const mutatedAuthData = {};
|
|
||||||
Object.keys(authData).forEach(provider => {
|
|
||||||
const providerData = authData[provider];
|
|
||||||
const userAuthData = userResult.authData[provider];
|
|
||||||
if (!_.isEqual(providerData, userAuthData)) {
|
|
||||||
mutatedAuthData[provider] = providerData;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const hasMutatedAuthData = Object.keys(mutatedAuthData).length !== 0;
|
|
||||||
let userId;
|
|
||||||
if (this.query && this.query.objectId) {
|
|
||||||
userId = this.query.objectId;
|
|
||||||
} else if (this.auth && this.auth.user && this.auth.user.id) {
|
} else if (this.auth && this.auth.user && this.auth.user.id) {
|
||||||
userId = this.auth.user.id;
|
return this.auth.user.id;
|
||||||
}
|
}
|
||||||
if (!userId || userId === userResult.objectId) {
|
};
|
||||||
|
|
||||||
|
// Developers are allowed to change authData via before save trigger
|
||||||
|
// we need after before save to ensure that the developer
|
||||||
|
// is not currently duplicating auth data ID
|
||||||
|
RestWrite.prototype.ensureUniqueAuthDataId = async function () {
|
||||||
|
if (this.className !== '_User' || !this.data.authData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAuthDataId = Object.keys(this.data.authData).some(
|
||||||
|
key => this.data.authData[key] && this.data.authData[key].id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasAuthDataId) return;
|
||||||
|
|
||||||
|
const r = await Auth.findUsersWithAuthData(this.config, this.data.authData);
|
||||||
|
const results = this.filteredObjectsByACL(r);
|
||||||
|
if (results.length > 1) {
|
||||||
|
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
|
||||||
|
}
|
||||||
|
// use data.objectId in case of login time and found user during handle validateAuthData
|
||||||
|
const userId = this.getUserId() || this.data.objectId;
|
||||||
|
if (results.length === 1 && userId !== results[0].objectId) {
|
||||||
|
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
RestWrite.prototype.handleAuthData = async function (authData) {
|
||||||
|
const r = await Auth.findUsersWithAuthData(this.config, authData);
|
||||||
|
const results = this.filteredObjectsByACL(r);
|
||||||
|
|
||||||
|
if (results.length > 1) {
|
||||||
|
// To avoid https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5
|
||||||
|
// Let's run some validation before throwing
|
||||||
|
await Auth.handleAuthDataValidation(authData, this, results[0]);
|
||||||
|
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
|
||||||
|
}
|
||||||
|
|
||||||
|
// No user found with provided authData we need to validate
|
||||||
|
if (!results.length) {
|
||||||
|
const { authData: validatedAuthData, authDataResponse } = await Auth.handleAuthDataValidation(
|
||||||
|
authData,
|
||||||
|
this
|
||||||
|
);
|
||||||
|
this.authDataResponse = authDataResponse;
|
||||||
|
// Replace current authData by the new validated one
|
||||||
|
this.data.authData = validatedAuthData;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User found with provided authData
|
||||||
|
if (results.length === 1) {
|
||||||
|
const userId = this.getUserId();
|
||||||
|
const userResult = results[0];
|
||||||
|
// Prevent duplicate authData id
|
||||||
|
if (userId && userId !== userResult.objectId) {
|
||||||
|
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.storage.authProvider = Object.keys(authData).join(',');
|
||||||
|
|
||||||
|
const { hasMutatedAuthData, mutatedAuthData } = Auth.hasMutatedAuthData(
|
||||||
|
authData,
|
||||||
|
userResult.authData
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCurrentUserLoggedOrMaster =
|
||||||
|
(this.auth && this.auth.user && this.auth.user.id === userResult.objectId) ||
|
||||||
|
this.auth.isMaster;
|
||||||
|
|
||||||
|
const isLogin = !userId;
|
||||||
|
|
||||||
|
if (isLogin || isCurrentUserLoggedOrMaster) {
|
||||||
// no user making the call
|
// no user making the call
|
||||||
// OR the user making the call is the right one
|
// OR the user making the call is the right one
|
||||||
// Login with auth data
|
// Login with auth data
|
||||||
@@ -512,7 +529,6 @@ RestWrite.prototype.handleAuthData = function (authData) {
|
|||||||
this.data.objectId = userResult.objectId;
|
this.data.objectId = userResult.objectId;
|
||||||
|
|
||||||
if (!this.query || !this.query.objectId) {
|
if (!this.query || !this.query.objectId) {
|
||||||
// this a login call, no userId passed
|
|
||||||
this.response = {
|
this.response = {
|
||||||
response: userResult,
|
response: userResult,
|
||||||
location: this.location(),
|
location: this.location(),
|
||||||
@@ -521,18 +537,35 @@ RestWrite.prototype.handleAuthData = function (authData) {
|
|||||||
// to authData on the db; changes to userResult
|
// to authData on the db; changes to userResult
|
||||||
// will be ignored.
|
// will be ignored.
|
||||||
await this.runBeforeLoginTrigger(deepcopy(userResult));
|
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 we didn't change the auth data, just keep going
|
// Prevent validating if no mutated data detected on update
|
||||||
if (!hasMutatedAuthData) {
|
if (!hasMutatedAuthData && isCurrentUserLoggedOrMaster) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// We have authData that is updated on login
|
|
||||||
// that can happen when token are refreshed,
|
// Force to validate all provided authData on login
|
||||||
// We should update the token and let the user in
|
// on update only validate mutated ones
|
||||||
// We should only check the mutated keys
|
if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) {
|
||||||
return this.handleAuthDataValidation(mutatedAuthData).then(async () => {
|
const res = await Auth.handleAuthDataValidation(
|
||||||
// IF we have a response, we'll skip the database operation / beforeSave / afterSave etc...
|
isLogin ? authData : mutatedAuthData,
|
||||||
|
this,
|
||||||
|
userResult
|
||||||
|
);
|
||||||
|
this.data.authData = res.authData;
|
||||||
|
this.authDataResponse = res.authDataResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IF we are in login we'll skip the database operation / beforeSave / afterSave etc...
|
||||||
// we need to set it up there.
|
// we 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
|
||||||
// If we're not logging in, but just updating the current user, we can safely skip that part
|
// If we're not logging in, but just updating the current user, we can safely skip that part
|
||||||
@@ -542,42 +575,26 @@ RestWrite.prototype.handleAuthData = function (authData) {
|
|||||||
this.response.response.authData[provider] = mutatedAuthData[provider];
|
this.response.response.authData[provider] = mutatedAuthData[provider];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run the DB update directly, as 'master'
|
// Run the DB update directly, as 'master' only if authData contains some keys
|
||||||
// Just update the authData part
|
// 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
|
// Then we're good for the user, early exit of sorts
|
||||||
return this.config.database.update(
|
if (Object.keys(this.data.authData).length) {
|
||||||
|
await this.config.database.update(
|
||||||
this.className,
|
this.className,
|
||||||
{ objectId: this.data.objectId },
|
{ objectId: this.data.objectId },
|
||||||
{ authData: mutatedAuthData },
|
{ authData: this.data.authData },
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
} 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
// The non-third-party parts of User transformation
|
||||||
RestWrite.prototype.transformUser = function () {
|
RestWrite.prototype.transformUser = function () {
|
||||||
var promise = Promise.resolve();
|
var promise = Promise.resolve();
|
||||||
|
|
||||||
if (this.className !== '_User') {
|
if (this.className !== '_User') {
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
@@ -848,7 +865,7 @@ RestWrite.prototype.createSessionTokenIfNeeded = function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!this.storage['authProvider'] && // signup call, with
|
!this.storage.authProvider && // signup call, with
|
||||||
this.config.preventLoginWithUnverifiedEmail && // no login without verification
|
this.config.preventLoginWithUnverifiedEmail && // no login without verification
|
||||||
this.config.verifyUserEmails
|
this.config.verifyUserEmails
|
||||||
) {
|
) {
|
||||||
@@ -865,15 +882,15 @@ RestWrite.prototype.createSessionToken = async function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.storage['authProvider'] == null && this.data.authData) {
|
if (this.storage.authProvider == null && this.data.authData) {
|
||||||
this.storage['authProvider'] = Object.keys(this.data.authData).join(',');
|
this.storage.authProvider = Object.keys(this.data.authData).join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessionData, createSession } = RestWrite.createSession(this.config, {
|
const { sessionData, createSession } = RestWrite.createSession(this.config, {
|
||||||
userId: this.objectId(),
|
userId: this.objectId(),
|
||||||
createdWith: {
|
createdWith: {
|
||||||
action: this.storage['authProvider'] ? 'login' : 'signup',
|
action: this.storage.authProvider ? 'login' : 'signup',
|
||||||
authProvider: this.storage['authProvider'] || 'password',
|
authProvider: this.storage.authProvider || 'password',
|
||||||
},
|
},
|
||||||
installationId: this.auth.installationId,
|
installationId: this.auth.installationId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ 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';
|
import {
|
||||||
|
maybeRunTrigger,
|
||||||
|
Types as TriggerTypes,
|
||||||
|
getRequestObject,
|
||||||
|
resolveError,
|
||||||
|
} from '../triggers';
|
||||||
import { promiseEnsureIdempotency } from '../middlewares';
|
import { promiseEnsureIdempotency } from '../middlewares';
|
||||||
import RestWrite from '../RestWrite';
|
import RestWrite from '../RestWrite';
|
||||||
|
import { logger } from '../logger';
|
||||||
|
|
||||||
export class UsersRouter extends ClassesRouter {
|
export class UsersRouter extends ClassesRouter {
|
||||||
className() {
|
className() {
|
||||||
@@ -174,7 +180,6 @@ export class UsersRouter extends ClassesRouter {
|
|||||||
|
|
||||||
// Remove hidden properties.
|
// Remove hidden properties.
|
||||||
UsersRouter.removeHiddenProperties(user);
|
UsersRouter.removeHiddenProperties(user);
|
||||||
|
|
||||||
return { response: user };
|
return { response: user };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -182,6 +187,30 @@ export class UsersRouter extends ClassesRouter {
|
|||||||
|
|
||||||
async handleLogIn(req) {
|
async handleLogIn(req) {
|
||||||
const user = await this._authenticateUserFromRequest(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
|
// handle password expiry policy
|
||||||
if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) {
|
if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) {
|
||||||
@@ -228,6 +257,16 @@ export class UsersRouter extends ClassesRouter {
|
|||||||
req.config
|
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, {
|
const { sessionData, createSession } = RestWrite.createSession(req.config, {
|
||||||
userId: user.objectId,
|
userId: user.objectId,
|
||||||
createdWith: {
|
createdWith: {
|
||||||
@@ -250,6 +289,10 @@ export class UsersRouter extends ClassesRouter {
|
|||||||
req.config
|
req.config
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (authDataResponse) {
|
||||||
|
user.authDataResponse = authDataResponse;
|
||||||
|
}
|
||||||
|
|
||||||
return { response: user };
|
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() {
|
mountRoutes() {
|
||||||
this.route('GET', '/users', req => {
|
this.route('GET', '/users', req => {
|
||||||
return this.handleFind(req);
|
return this.handleFind(req);
|
||||||
@@ -493,6 +657,9 @@ export class UsersRouter extends ClassesRouter {
|
|||||||
this.route('GET', '/verifyPassword', req => {
|
this.route('GET', '/verifyPassword', req => {
|
||||||
return this.handleVerifyPassword(req);
|
return this.handleVerifyPassword(req);
|
||||||
});
|
});
|
||||||
|
this.route('POST', '/challenge', req => {
|
||||||
|
return this.handleChallenge(req);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -728,6 +728,7 @@ module.exports = ParseCloud;
|
|||||||
* @interface Parse.Cloud.TriggerRequest
|
* @interface Parse.Cloud.TriggerRequest
|
||||||
* @property {String} installationId If set, the installationId triggering the request.
|
* @property {String} installationId If set, the installationId triggering the request.
|
||||||
* @property {Boolean} master If true, means the master key was used.
|
* @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.User} user If set, the user that made the request.
|
||||||
* @property {Parse.Object} object The object triggering the hook.
|
* @property {Parse.Object} object The object triggering the hook.
|
||||||
* @property {String} ip The IP address of the client making the request.
|
* @property {String} ip The IP address of the client making the request.
|
||||||
|
|||||||
Reference in New Issue
Block a user