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 | - |
|
||||
| DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - |
|
||||
| DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - |
|
||||
| DEPPS8 | Login with expired 3rd party authentication token defaults to `false` | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - |
|
||||
|
||||
[i_deprecation]: ## "The version and date of the deprecation."
|
||||
[i_removal]: ## "The version and date of the planned removal."
|
||||
|
||||
@@ -256,6 +256,49 @@ describe('AuthenticationProviders', function () {
|
||||
.catch(done.fail);
|
||||
});
|
||||
|
||||
it('should support loginWith with session token and with/without mutated authData', async () => {
|
||||
const fakeAuthProvider = {
|
||||
validateAppId: () => Promise.resolve(),
|
||||
validateAuthData: () => Promise.resolve(),
|
||||
};
|
||||
const payload = { authData: { id: 'user1', token: 'fakeToken' } };
|
||||
const payload2 = { authData: { id: 'user1', token: 'fakeToken2' } };
|
||||
await reconfigureServer({ auth: { fakeAuthProvider } });
|
||||
const user = await Parse.User.logInWith('fakeAuthProvider', payload);
|
||||
const user2 = await Parse.User.logInWith('fakeAuthProvider', payload, {
|
||||
sessionToken: user.getSessionToken(),
|
||||
});
|
||||
const user3 = await Parse.User.logInWith('fakeAuthProvider', payload2, {
|
||||
sessionToken: user2.getSessionToken(),
|
||||
});
|
||||
expect(user.id).toEqual(user2.id);
|
||||
expect(user.id).toEqual(user3.id);
|
||||
});
|
||||
|
||||
it('should support sync/async validateAppId', async () => {
|
||||
const syncProvider = {
|
||||
validateAppId: () => true,
|
||||
appIds: 'test',
|
||||
validateAuthData: () => Promise.resolve(),
|
||||
};
|
||||
const asyncProvider = {
|
||||
appIds: 'test',
|
||||
validateAppId: () => Promise.resolve(true),
|
||||
validateAuthData: () => Promise.resolve(),
|
||||
};
|
||||
const payload = { authData: { id: 'user1', token: 'fakeToken' } };
|
||||
const syncSpy = spyOn(syncProvider, 'validateAppId');
|
||||
const asyncSpy = spyOn(asyncProvider, 'validateAppId');
|
||||
|
||||
await reconfigureServer({ auth: { asyncProvider, syncProvider } });
|
||||
const user = await Parse.User.logInWith('asyncProvider', payload);
|
||||
const user2 = await Parse.User.logInWith('syncProvider', payload);
|
||||
expect(user.getSessionToken()).toBeDefined();
|
||||
expect(user2.getSessionToken()).toBeDefined();
|
||||
expect(syncSpy).toHaveBeenCalledTimes(1);
|
||||
expect(asyncSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unlink and link with custom provider', async () => {
|
||||
const provider = getMockMyOauthProvider();
|
||||
Parse.User._registerAuthenticationProvider(provider);
|
||||
@@ -339,10 +382,10 @@ describe('AuthenticationProviders', function () {
|
||||
});
|
||||
|
||||
validateAuthenticationHandler(authenticationHandler);
|
||||
const validator = authenticationHandler.getValidatorForProvider('customAuthentication');
|
||||
const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication');
|
||||
validateValidator(validator);
|
||||
|
||||
validator(validAuthData).then(
|
||||
validator(validAuthData, {}, {}).then(
|
||||
() => {
|
||||
expect(authDataSpy).toHaveBeenCalled();
|
||||
// AppIds are not provided in the adapter, should not be called
|
||||
@@ -362,12 +405,15 @@ describe('AuthenticationProviders', function () {
|
||||
});
|
||||
|
||||
validateAuthenticationHandler(authenticationHandler);
|
||||
const validator = authenticationHandler.getValidatorForProvider('customAuthentication');
|
||||
const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication');
|
||||
validateValidator(validator);
|
||||
|
||||
validator({
|
||||
token: 'my-token',
|
||||
}).then(
|
||||
validator(
|
||||
{
|
||||
token: 'my-token',
|
||||
},
|
||||
{},
|
||||
{}
|
||||
).then(
|
||||
() => {
|
||||
done();
|
||||
},
|
||||
@@ -387,12 +433,16 @@ describe('AuthenticationProviders', function () {
|
||||
});
|
||||
|
||||
validateAuthenticationHandler(authenticationHandler);
|
||||
const validator = authenticationHandler.getValidatorForProvider('customAuthentication');
|
||||
const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication');
|
||||
validateValidator(validator);
|
||||
|
||||
validator({
|
||||
token: 'valid-token',
|
||||
}).then(
|
||||
validator(
|
||||
{
|
||||
token: 'valid-token',
|
||||
},
|
||||
{},
|
||||
{}
|
||||
).then(
|
||||
() => {
|
||||
done();
|
||||
},
|
||||
@@ -541,6 +591,7 @@ describe('AuthenticationProviders', function () {
|
||||
});
|
||||
|
||||
it('can depreciate', async () => {
|
||||
await reconfigureServer();
|
||||
const Deprecator = require('../lib/Deprecator/Deprecator');
|
||||
const spy = spyOn(Deprecator, 'logRuntimeDeprecation').and.callFake(() => {});
|
||||
const provider = getMockMyOauthProvider();
|
||||
|
||||
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
|
||||
.map(field => field.name)
|
||||
.sort();
|
||||
|
||||
expect(inputFields).toEqual(['clientMutationId', 'password', 'username']);
|
||||
expect(inputFields).toEqual(['authData', 'clientMutationId', 'password', 'username']);
|
||||
});
|
||||
|
||||
it('should have clientMutationId in log in mutation payload', async () => {
|
||||
@@ -7027,7 +7026,61 @@ describe('ParseGraphQLServer', () => {
|
||||
});
|
||||
|
||||
describe('Users Mutations', () => {
|
||||
const challengeAdapter = {
|
||||
validateAuthData: () => Promise.resolve({ response: { someData: true } }),
|
||||
validateAppId: () => Promise.resolve(),
|
||||
challenge: () => Promise.resolve({ someData: true }),
|
||||
options: { anOption: true },
|
||||
};
|
||||
|
||||
it('should create user and return authData response', async () => {
|
||||
parseServer = await global.reconfigureServer({
|
||||
publicServerURL: 'http://localhost:13377/parse',
|
||||
auth: {
|
||||
challengeAdapter,
|
||||
},
|
||||
});
|
||||
const clientMutationId = uuidv4();
|
||||
|
||||
const result = await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation createUser($input: CreateUserInput!) {
|
||||
createUser(input: $input) {
|
||||
clientMutationId
|
||||
user {
|
||||
id
|
||||
authDataResponse
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
clientMutationId,
|
||||
fields: {
|
||||
authData: {
|
||||
challengeAdapter: {
|
||||
id: 'challengeAdapter',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.data.createUser.clientMutationId).toEqual(clientMutationId);
|
||||
expect(result.data.createUser.user.authDataResponse).toEqual({
|
||||
challengeAdapter: { someData: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should sign user up', async () => {
|
||||
parseServer = await global.reconfigureServer({
|
||||
publicServerURL: 'http://localhost:13377/parse',
|
||||
auth: {
|
||||
challengeAdapter,
|
||||
},
|
||||
});
|
||||
const clientMutationId = uuidv4();
|
||||
const userSchema = new Parse.Schema('_User');
|
||||
userSchema.addString('someField');
|
||||
@@ -7044,6 +7097,7 @@ describe('ParseGraphQLServer', () => {
|
||||
sessionToken
|
||||
user {
|
||||
someField
|
||||
authDataResponse
|
||||
aPointer {
|
||||
id
|
||||
username
|
||||
@@ -7059,6 +7113,11 @@ describe('ParseGraphQLServer', () => {
|
||||
fields: {
|
||||
username: 'user1',
|
||||
password: 'user1',
|
||||
authData: {
|
||||
challengeAdapter: {
|
||||
id: 'challengeAdapter',
|
||||
},
|
||||
},
|
||||
aPointer: {
|
||||
createAndLink: {
|
||||
username: 'user2',
|
||||
@@ -7078,6 +7137,9 @@ describe('ParseGraphQLServer', () => {
|
||||
expect(result.data.signUp.viewer.user.aPointer.id).toBeDefined();
|
||||
expect(result.data.signUp.viewer.user.aPointer.username).toEqual('user2');
|
||||
expect(typeof result.data.signUp.viewer.sessionToken).toBe('string');
|
||||
expect(result.data.signUp.viewer.user.authDataResponse).toEqual({
|
||||
challengeAdapter: { someData: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should login with user', async () => {
|
||||
@@ -7086,6 +7148,7 @@ describe('ParseGraphQLServer', () => {
|
||||
parseServer = await global.reconfigureServer({
|
||||
publicServerURL: 'http://localhost:13377/parse',
|
||||
auth: {
|
||||
challengeAdapter,
|
||||
myAuth: {
|
||||
module: global.mockCustomAuthenticator('parse', 'graphql'),
|
||||
},
|
||||
@@ -7105,6 +7168,7 @@ describe('ParseGraphQLServer', () => {
|
||||
sessionToken
|
||||
user {
|
||||
someField
|
||||
authDataResponse
|
||||
aPointer {
|
||||
id
|
||||
username
|
||||
@@ -7118,6 +7182,7 @@ describe('ParseGraphQLServer', () => {
|
||||
input: {
|
||||
clientMutationId,
|
||||
authData: {
|
||||
challengeAdapter: { id: 'challengeAdapter' },
|
||||
myAuth: {
|
||||
id: 'parse',
|
||||
password: 'graphql',
|
||||
@@ -7143,9 +7208,92 @@ describe('ParseGraphQLServer', () => {
|
||||
expect(typeof result.data.logInWith.viewer.sessionToken).toBe('string');
|
||||
expect(result.data.logInWith.viewer.user.aPointer.id).toBeDefined();
|
||||
expect(result.data.logInWith.viewer.user.aPointer.username).toEqual('user2');
|
||||
expect(result.data.logInWith.viewer.user.authDataResponse).toEqual({
|
||||
challengeAdapter: { someData: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle challenge', async () => {
|
||||
const clientMutationId = uuidv4();
|
||||
|
||||
spyOn(challengeAdapter, 'challenge').and.callThrough();
|
||||
parseServer = await global.reconfigureServer({
|
||||
publicServerURL: 'http://localhost:13377/parse',
|
||||
auth: {
|
||||
challengeAdapter,
|
||||
},
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
await user.save({ username: 'username', password: 'password' });
|
||||
|
||||
const result = await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Challenge($input: ChallengeInput!) {
|
||||
challenge(input: $input) {
|
||||
clientMutationId
|
||||
challengeData
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
clientMutationId,
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
challengeData: {
|
||||
challengeAdapter: { someChallengeData: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const challengeCall = challengeAdapter.challenge.calls.argsFor(0);
|
||||
expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1);
|
||||
expect(challengeCall[0]).toEqual({ someChallengeData: true });
|
||||
expect(challengeCall[1]).toEqual(undefined);
|
||||
expect(challengeCall[2]).toEqual(challengeAdapter);
|
||||
expect(challengeCall[3].object instanceof Parse.User).toBeTruthy();
|
||||
expect(challengeCall[3].original instanceof Parse.User).toBeTruthy();
|
||||
expect(challengeCall[3].isChallenge).toBeTruthy();
|
||||
expect(challengeCall[3].object.id).toEqual(user.id);
|
||||
expect(challengeCall[3].original.id).toEqual(user.id);
|
||||
expect(result.data.challenge.clientMutationId).toEqual(clientMutationId);
|
||||
expect(result.data.challenge.challengeData).toEqual({
|
||||
challengeAdapter: { someData: true },
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Challenge($input: ChallengeInput!) {
|
||||
challenge(input: $input) {
|
||||
clientMutationId
|
||||
challengeData
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
clientMutationId,
|
||||
username: 'username',
|
||||
password: 'wrongPassword',
|
||||
challengeData: {
|
||||
challengeAdapter: { someChallengeData: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBeRejected();
|
||||
});
|
||||
|
||||
it('should log the user in', async () => {
|
||||
parseServer = await global.reconfigureServer({
|
||||
publicServerURL: 'http://localhost:13377/parse',
|
||||
auth: {
|
||||
challengeAdapter,
|
||||
},
|
||||
});
|
||||
const clientMutationId = uuidv4();
|
||||
const user = new Parse.User();
|
||||
user.setUsername('user1');
|
||||
@@ -7162,6 +7310,7 @@ describe('ParseGraphQLServer', () => {
|
||||
viewer {
|
||||
sessionToken
|
||||
user {
|
||||
authDataResponse
|
||||
someField
|
||||
}
|
||||
}
|
||||
@@ -7173,6 +7322,7 @@ describe('ParseGraphQLServer', () => {
|
||||
clientMutationId,
|
||||
username: 'user1',
|
||||
password: 'user1',
|
||||
authData: { challengeAdapter: { token: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -7181,6 +7331,9 @@ describe('ParseGraphQLServer', () => {
|
||||
expect(result.data.logIn.viewer.sessionToken).toBeDefined();
|
||||
expect(result.data.logIn.viewer.user.someField).toEqual('someValue');
|
||||
expect(typeof result.data.logIn.viewer.sessionToken).toBe('string');
|
||||
expect(result.data.logIn.viewer.user.authDataResponse).toEqual({
|
||||
challengeAdapter: { someData: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should log the user out', async () => {
|
||||
|
||||
@@ -13,6 +13,63 @@ const passwordCrypto = require('../lib/password');
|
||||
const Config = require('../lib/Config');
|
||||
const cryptoUtils = require('../lib/cryptoUtils');
|
||||
|
||||
describe('allowExpiredAuthDataToken option', () => {
|
||||
it('should accept true value', async () => {
|
||||
const logger = require('../lib/logger').logger;
|
||||
const logSpy = spyOn(logger, 'warn').and.callFake(() => {});
|
||||
await reconfigureServer({ allowExpiredAuthDataToken: true });
|
||||
expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(true);
|
||||
expect(
|
||||
logSpy.calls
|
||||
.all()
|
||||
.filter(
|
||||
log =>
|
||||
log.args[0] ===
|
||||
`DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.`
|
||||
).length
|
||||
).toEqual(0);
|
||||
});
|
||||
|
||||
it('should accept false value', async () => {
|
||||
const logger = require('../lib/logger').logger;
|
||||
const logSpy = spyOn(logger, 'warn').and.callFake(() => {});
|
||||
await reconfigureServer({ allowExpiredAuthDataToken: false });
|
||||
expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(false);
|
||||
expect(
|
||||
logSpy.calls
|
||||
.all()
|
||||
.filter(
|
||||
log =>
|
||||
log.args[0] ===
|
||||
`DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.`
|
||||
).length
|
||||
).toEqual(0);
|
||||
});
|
||||
|
||||
it('should default true', async () => {
|
||||
const logger = require('../lib/logger').logger;
|
||||
const logSpy = spyOn(logger, 'warn').and.callFake(() => {});
|
||||
await reconfigureServer({});
|
||||
expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(true);
|
||||
expect(
|
||||
logSpy.calls
|
||||
.all()
|
||||
.filter(
|
||||
log =>
|
||||
log.args[0] ===
|
||||
`DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.`
|
||||
).length
|
||||
).toEqual(1);
|
||||
});
|
||||
|
||||
it('should enforce boolean values', async () => {
|
||||
const options = [[], 'a', '', 0, 1, {}, 'true', 'false'];
|
||||
for (const option of options) {
|
||||
await expectAsync(reconfigureServer({ allowExpiredAuthDataToken: option })).toBeRejected();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parse.User testing', () => {
|
||||
it('user sign up class method', async done => {
|
||||
const user = await Parse.User.signUp('asdf', 'zxcv');
|
||||
@@ -1129,7 +1186,7 @@ describe('Parse.User testing', () => {
|
||||
this.synchronizedExpiration = authData.expiration_date;
|
||||
return true;
|
||||
},
|
||||
getAuthType: function () {
|
||||
getAuthType() {
|
||||
return 'facebook';
|
||||
},
|
||||
deauthenticate: function () {
|
||||
@@ -1158,7 +1215,7 @@ describe('Parse.User testing', () => {
|
||||
synchronizedAuthToken: null,
|
||||
synchronizedExpiration: null,
|
||||
|
||||
authenticate: function (options) {
|
||||
authenticate(options) {
|
||||
if (this.shouldError) {
|
||||
options.error(this, 'An error occurred');
|
||||
} else if (this.shouldCancel) {
|
||||
@@ -1167,7 +1224,7 @@ describe('Parse.User testing', () => {
|
||||
options.success(this, this.authData);
|
||||
}
|
||||
},
|
||||
restoreAuthentication: function (authData) {
|
||||
restoreAuthentication(authData) {
|
||||
if (!authData) {
|
||||
this.synchronizedUserId = null;
|
||||
this.synchronizedAuthToken = null;
|
||||
@@ -1179,10 +1236,10 @@ describe('Parse.User testing', () => {
|
||||
this.synchronizedExpiration = authData.expiration_date;
|
||||
return true;
|
||||
},
|
||||
getAuthType: function () {
|
||||
getAuthType() {
|
||||
return 'myoauth';
|
||||
},
|
||||
deauthenticate: function () {
|
||||
deauthenticate() {
|
||||
this.loggedOut = true;
|
||||
this.restoreAuthentication(null);
|
||||
},
|
||||
@@ -1792,7 +1849,7 @@ describe('Parse.User testing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow login with old authData token', done => {
|
||||
it('should allow login with expired authData token by default', async () => {
|
||||
const provider = {
|
||||
authData: {
|
||||
id: '12345',
|
||||
@@ -1813,22 +1870,42 @@ describe('Parse.User testing', () => {
|
||||
};
|
||||
defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token');
|
||||
Parse.User._registerAuthenticationProvider(provider);
|
||||
Parse.User._logInWith('shortLivedAuth', {})
|
||||
.then(() => {
|
||||
// Simulate a remotely expired token (like a short lived one)
|
||||
// In this case, we want success as it was valid once.
|
||||
// If the client needs an updated one, do lock the user out
|
||||
defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken');
|
||||
return Parse.User._logInWith('shortLivedAuth', {});
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
done();
|
||||
},
|
||||
err => {
|
||||
done.fail(err);
|
||||
}
|
||||
);
|
||||
await Parse.User._logInWith('shortLivedAuth', {});
|
||||
// Simulate a remotely expired token (like a short lived one)
|
||||
// In this case, we want success as it was valid once.
|
||||
// If the client needs an updated token, do lock the user out
|
||||
defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken');
|
||||
await Parse.User._logInWith('shortLivedAuth', {});
|
||||
});
|
||||
|
||||
it('should not allow login with expired authData token when allowExpiredAuthDataToken is set to false', async () => {
|
||||
await reconfigureServer({ allowExpiredAuthDataToken: false });
|
||||
const provider = {
|
||||
authData: {
|
||||
id: '12345',
|
||||
access_token: 'token',
|
||||
},
|
||||
restoreAuthentication() {
|
||||
return true;
|
||||
},
|
||||
deauthenticate() {
|
||||
provider.authData = {};
|
||||
},
|
||||
authenticate(options) {
|
||||
options.success(this, provider.authData);
|
||||
},
|
||||
getAuthType() {
|
||||
return 'shortLivedAuth';
|
||||
},
|
||||
};
|
||||
defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token');
|
||||
Parse.User._registerAuthenticationProvider(provider);
|
||||
await Parse.User._logInWith('shortLivedAuth', {});
|
||||
// Simulate a remotely expired token (like a short lived one)
|
||||
// In this case, we want success as it was valid once.
|
||||
// If the client needs an updated token, do lock the user out
|
||||
defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken');
|
||||
expectAsync(Parse.User._logInWith('shortLivedAuth', {})).toBeRejected();
|
||||
});
|
||||
|
||||
it('should allow PUT request with stale auth Data', done => {
|
||||
@@ -2260,37 +2337,14 @@ describe('Parse.User testing', () => {
|
||||
});
|
||||
|
||||
describe('anonymous users', () => {
|
||||
beforeEach(() => {
|
||||
const insensitiveCollisions = [
|
||||
'abcdefghijklmnop',
|
||||
'Abcdefghijklmnop',
|
||||
'ABcdefghijklmnop',
|
||||
'ABCdefghijklmnop',
|
||||
'ABCDefghijklmnop',
|
||||
'ABCDEfghijklmnop',
|
||||
'ABCDEFghijklmnop',
|
||||
'ABCDEFGhijklmnop',
|
||||
'ABCDEFGHijklmnop',
|
||||
'ABCDEFGHIjklmnop',
|
||||
'ABCDEFGHIJklmnop',
|
||||
'ABCDEFGHIJKlmnop',
|
||||
'ABCDEFGHIJKLmnop',
|
||||
'ABCDEFGHIJKLMnop',
|
||||
'ABCDEFGHIJKLMnop',
|
||||
'ABCDEFGHIJKLMNop',
|
||||
'ABCDEFGHIJKLMNOp',
|
||||
'ABCDEFGHIJKLMNOP',
|
||||
];
|
||||
|
||||
// need a bunch of spare random strings per api request
|
||||
spyOn(cryptoUtils, 'randomString').and.returnValues(...insensitiveCollisions);
|
||||
});
|
||||
|
||||
it('should not fail on case insensitive matches', async () => {
|
||||
const user1 = await Parse.AnonymousUtils.logIn();
|
||||
spyOn(cryptoUtils, 'randomString').and.returnValue('abcdefghijklmnop');
|
||||
const logIn = id => Parse.User.logInWith('anonymous', { authData: { id } });
|
||||
const user1 = await logIn('test1');
|
||||
const username1 = user1.get('username');
|
||||
|
||||
const user2 = await Parse.AnonymousUtils.logIn();
|
||||
cryptoUtils.randomString.and.returnValue('ABCDEFGHIJKLMNOp');
|
||||
const user2 = await logIn('test2');
|
||||
const username2 = user2.get('username');
|
||||
|
||||
expect(username1).not.toBeUndefined();
|
||||
|
||||
@@ -1,20 +1,96 @@
|
||||
/*eslint no-unused-vars: "off"*/
|
||||
|
||||
/**
|
||||
* @interface ParseAuthResponse
|
||||
* @property {Boolean} [doNotSave] If true, Parse Server will not save provided authData.
|
||||
* @property {Object} [response] If set, Parse Server will send the provided response to the client under authDataResponse
|
||||
* @property {Object} [save] If set, Parse Server will save the object provided into this key, instead of client provided authData
|
||||
*/
|
||||
|
||||
/**
|
||||
* AuthPolicy
|
||||
* default: can be combined with ONE additional auth provider if additional configured on user
|
||||
* additional: could be only used with a default policy auth provider
|
||||
* solo: Will ignore ALL additional providers if additional configured on user
|
||||
* @typedef {"default" | "additional" | "solo"} AuthPolicy
|
||||
*/
|
||||
|
||||
export class AuthAdapter {
|
||||
/*
|
||||
@param appIds: the specified app ids in the configuration
|
||||
@param authData: the client provided authData
|
||||
@param options: additional options
|
||||
@returns a promise that resolves if the applicationId is valid
|
||||
constructor() {
|
||||
/**
|
||||
* Usage policy
|
||||
* @type {AuthPolicy}
|
||||
*/
|
||||
this.policy = 'default';
|
||||
}
|
||||
/**
|
||||
* @param appIds The specified app IDs in the configuration
|
||||
* @param {Object} authData The client provided authData
|
||||
* @param {Object} options additional adapter options
|
||||
* @param {Parse.Cloud.TriggerRequest} request
|
||||
* @returns {(Promise<undefined|void>|void|undefined)} resolves or returns if the applicationId is valid
|
||||
*/
|
||||
validateAppId(appIds, authData, options) {
|
||||
validateAppId(appIds, authData, options, request) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
/*
|
||||
@param authData: the client provided authData
|
||||
@param options: additional options
|
||||
/**
|
||||
* Legacy usage, if provided it will be triggered when authData related to this provider is touched (signup/update/login)
|
||||
* otherwise you should implement validateSetup, validateLogin and validateUpdate
|
||||
* @param {Object} authData The client provided authData
|
||||
* @param {Parse.Cloud.TriggerRequest} request
|
||||
* @param {Object} options additional adapter options
|
||||
* @returns {Promise<ParseAuthResponse|void|undefined>}
|
||||
*/
|
||||
validateAuthData(authData, options) {
|
||||
validateAuthData(authData, request, options) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when user provide for the first time this auth provider
|
||||
* could be a register or the user adding a new auth service
|
||||
* @param {Object} authData The client provided authData
|
||||
* @param {Parse.Cloud.TriggerRequest} request
|
||||
* @param {Object} options additional adapter options
|
||||
* @returns {Promise<ParseAuthResponse|void|undefined>}
|
||||
*/
|
||||
validateSetUp(authData, req, options) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when user provide authData related to this provider
|
||||
* The user is not logged in and has already set this provider before
|
||||
* @param {Object} authData The client provided authData
|
||||
* @param {Parse.Cloud.TriggerRequest} request
|
||||
* @param {Object} options additional adapter options
|
||||
* @returns {Promise<ParseAuthResponse|void|undefined>}
|
||||
*/
|
||||
validateLogin(authData, req, options) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when user provide authData related to this provider
|
||||
* the user is logged in and has already set this provider before
|
||||
* @param {Object} authData The client provided authData
|
||||
* @param {Object} options additional adapter options
|
||||
* @param {Parse.Cloud.TriggerRequest} request
|
||||
* @returns {Promise<ParseAuthResponse|void|undefined>}
|
||||
*/
|
||||
validateUpdate(authData, req, options) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered in pre authentication process if needed (like webauthn, SMS OTP)
|
||||
* @param {Object} challengeData Data provided by the client
|
||||
* @param {(Object|undefined)} authData Auth data provided by the client, can be used for validation
|
||||
* @param {Object} options additional adapter options
|
||||
* @param {Parse.Cloud.TriggerRequest} request
|
||||
* @returns {Promise<Object>} A promise that resolves, resolved value will be added to challenge response under challenge key
|
||||
*/
|
||||
challenge(challengeData, authData, options, request) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import loadAdapter from '../AdapterLoader';
|
||||
import Parse from 'parse/node';
|
||||
|
||||
const apple = require('./apple');
|
||||
const gcenter = require('./gcenter');
|
||||
@@ -61,19 +62,83 @@ const providers = {
|
||||
ldap,
|
||||
};
|
||||
|
||||
function authDataValidator(adapter, appIds, options) {
|
||||
return function (authData) {
|
||||
return adapter.validateAuthData(authData, options).then(() => {
|
||||
if (appIds) {
|
||||
return adapter.validateAppId(appIds, authData, options);
|
||||
// Indexed auth policies
|
||||
const authAdapterPolicies = {
|
||||
default: true,
|
||||
solo: true,
|
||||
additional: true,
|
||||
};
|
||||
|
||||
function authDataValidator(provider, adapter, appIds, options) {
|
||||
return async function (authData, req, user, requestObject) {
|
||||
if (appIds && typeof adapter.validateAppId === 'function') {
|
||||
await Promise.resolve(adapter.validateAppId(appIds, authData, options, requestObject));
|
||||
}
|
||||
if (adapter.policy && !authAdapterPolicies[adapter.policy]) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OTHER_CAUSE,
|
||||
'AuthAdapter policy is not configured correctly. The value must be either "solo", "additional", "default" or undefined (will be handled as "default")'
|
||||
);
|
||||
}
|
||||
if (typeof adapter.validateAuthData === 'function') {
|
||||
return adapter.validateAuthData(authData, options, requestObject);
|
||||
}
|
||||
if (
|
||||
typeof adapter.validateSetUp !== 'function' ||
|
||||
typeof adapter.validateLogin !== 'function' ||
|
||||
typeof adapter.validateUpdate !== 'function'
|
||||
) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OTHER_CAUSE,
|
||||
'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate'
|
||||
);
|
||||
}
|
||||
// When masterKey is detected, we should trigger a logged in user
|
||||
const isLoggedIn =
|
||||
(req.auth.user && user && req.auth.user.id === user.id) || (user && req.auth.isMaster);
|
||||
let hasAuthDataConfigured = false;
|
||||
|
||||
if (user && user.get('authData') && user.get('authData')[provider]) {
|
||||
hasAuthDataConfigured = true;
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
// User is updating their authData
|
||||
if (hasAuthDataConfigured) {
|
||||
return {
|
||||
method: 'validateUpdate',
|
||||
validator: () => adapter.validateUpdate(authData, options, requestObject),
|
||||
};
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
// Set up if the user does not have the provider configured
|
||||
return {
|
||||
method: 'validateSetUp',
|
||||
validator: () => adapter.validateSetUp(authData, options, requestObject),
|
||||
};
|
||||
}
|
||||
|
||||
// Not logged in and authData is configured on the user
|
||||
if (hasAuthDataConfigured) {
|
||||
return {
|
||||
method: 'validateLogin',
|
||||
validator: () => adapter.validateLogin(authData, options, requestObject),
|
||||
};
|
||||
}
|
||||
|
||||
// User not logged in and the provider is not set up, for example when a new user
|
||||
// signs up or an existing user uses a new auth provider
|
||||
return {
|
||||
method: 'validateSetUp',
|
||||
validator: () => adapter.validateSetUp(authData, options, requestObject),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function loadAuthAdapter(provider, authOptions) {
|
||||
// providers are auth providers implemented by default
|
||||
let defaultAdapter = providers[provider];
|
||||
// authOptions can contain complete custom auth adapters or
|
||||
// a default auth adapter like Facebook
|
||||
const providerOptions = authOptions[provider];
|
||||
if (
|
||||
providerOptions &&
|
||||
@@ -83,6 +148,7 @@ function loadAuthAdapter(provider, authOptions) {
|
||||
defaultAdapter = oauth2;
|
||||
}
|
||||
|
||||
// Default provider not found and a custom auth provider was not provided
|
||||
if (!defaultAdapter && !providerOptions) {
|
||||
return;
|
||||
}
|
||||
@@ -94,7 +160,15 @@ function loadAuthAdapter(provider, authOptions) {
|
||||
if (providerOptions) {
|
||||
const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions);
|
||||
if (optionalAdapter) {
|
||||
['validateAuthData', 'validateAppId'].forEach(key => {
|
||||
[
|
||||
'validateAuthData',
|
||||
'validateAppId',
|
||||
'validateSetUp',
|
||||
'validateLogin',
|
||||
'validateUpdate',
|
||||
'challenge',
|
||||
'policy',
|
||||
].forEach(key => {
|
||||
if (optionalAdapter[key]) {
|
||||
adapter[key] = optionalAdapter[key];
|
||||
}
|
||||
@@ -102,14 +176,6 @@ function loadAuthAdapter(provider, authOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: create a new module from validateAdapter() in
|
||||
// src/Controllers/AdaptableController.js so we can use it here for adapter
|
||||
// validation based on the src/Adapters/Auth/AuthAdapter.js expected class
|
||||
// signature.
|
||||
if (!adapter.validateAuthData || !adapter.validateAppId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { adapter, appIds, providerOptions };
|
||||
}
|
||||
|
||||
@@ -121,12 +187,12 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) {
|
||||
// To handle the test cases on configuration
|
||||
const getValidatorForProvider = function (provider) {
|
||||
if (provider === 'anonymous' && !_enableAnonymousUsers) {
|
||||
return;
|
||||
return { validator: undefined };
|
||||
}
|
||||
|
||||
const { adapter, appIds, providerOptions } = loadAuthAdapter(provider, authOptions);
|
||||
|
||||
return authDataValidator(adapter, appIds, providerOptions);
|
||||
const authAdapter = loadAuthAdapter(provider, authOptions);
|
||||
if (!authAdapter) return;
|
||||
const { adapter, appIds, providerOptions } = authAdapter;
|
||||
return { validator: authDataValidator(provider, adapter, appIds, providerOptions), adapter };
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
|
||||
@@ -289,7 +289,6 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/);
|
||||
if (authDataMatch) {
|
||||
// TODO: Handle querying by _auth_data_provider, authData is stored in authData field
|
||||
@@ -1322,12 +1321,17 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
return;
|
||||
}
|
||||
var authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/);
|
||||
const authDataAlreadyExists = !!object.authData;
|
||||
if (authDataMatch) {
|
||||
var provider = authDataMatch[1];
|
||||
object['authData'] = object['authData'] || {};
|
||||
object['authData'][provider] = object[fieldName];
|
||||
delete object[fieldName];
|
||||
fieldName = 'authData';
|
||||
// Avoid adding authData multiple times to the query
|
||||
if (authDataAlreadyExists) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
columnsArray.push(fieldName);
|
||||
@@ -1807,7 +1811,6 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
caseInsensitive,
|
||||
});
|
||||
values.push(...where.values);
|
||||
|
||||
const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : '';
|
||||
const limitPattern = hasLimit ? `LIMIT $${values.length + 1}` : '';
|
||||
if (hasLimit) {
|
||||
|
||||
191
src/Auth.js
191
src/Auth.js
@@ -1,5 +1,8 @@
|
||||
const RestQuery = require('./RestQuery');
|
||||
const Parse = require('parse/node');
|
||||
import { isDeepStrictEqual } from 'util';
|
||||
import { getRequestObject, resolveError } from './triggers';
|
||||
import Deprecator from './Deprecator/Deprecator';
|
||||
import { logger } from './logger';
|
||||
|
||||
// An Auth object tells you who is requesting something and whether
|
||||
// the master key was used.
|
||||
@@ -83,7 +86,7 @@ const getAuthForSessionToken = async function ({
|
||||
limit: 1,
|
||||
include: 'user',
|
||||
};
|
||||
|
||||
const RestQuery = require('./RestQuery');
|
||||
const query = new RestQuery(config, master(config), '_Session', { sessionToken }, restOptions);
|
||||
results = (await query.execute()).results;
|
||||
} else {
|
||||
@@ -125,6 +128,7 @@ var getAuthForLegacySessionToken = function ({ config, sessionToken, installatio
|
||||
var restOptions = {
|
||||
limit: 1,
|
||||
};
|
||||
const RestQuery = require('./RestQuery');
|
||||
var query = new RestQuery(config, master(config), '_User', { sessionToken }, restOptions);
|
||||
return query.execute().then(response => {
|
||||
var results = response.results;
|
||||
@@ -169,6 +173,7 @@ Auth.prototype.getRolesForUser = async function () {
|
||||
objectId: this.user.id,
|
||||
},
|
||||
};
|
||||
const RestQuery = require('./RestQuery');
|
||||
await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result =>
|
||||
results.push(result)
|
||||
);
|
||||
@@ -262,6 +267,7 @@ Auth.prototype.getRolesByIds = async function (ins) {
|
||||
};
|
||||
});
|
||||
const restWhere = { roles: { $in: roles } };
|
||||
const RestQuery = require('./RestQuery');
|
||||
await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result =>
|
||||
results.push(result)
|
||||
);
|
||||
@@ -307,6 +313,183 @@ Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], quer
|
||||
});
|
||||
};
|
||||
|
||||
const findUsersWithAuthData = (config, authData) => {
|
||||
const providers = Object.keys(authData);
|
||||
const query = providers
|
||||
.reduce((memo, provider) => {
|
||||
if (!authData[provider] || (authData && !authData[provider].id)) {
|
||||
return memo;
|
||||
}
|
||||
const queryKey = `authData.${provider}.id`;
|
||||
const query = {};
|
||||
query[queryKey] = authData[provider].id;
|
||||
memo.push(query);
|
||||
return memo;
|
||||
}, [])
|
||||
.filter(q => {
|
||||
return typeof q !== 'undefined';
|
||||
});
|
||||
|
||||
return query.length > 0
|
||||
? config.database.find('_User', { $or: query }, { limit: 2 })
|
||||
: Promise.resolve([]);
|
||||
};
|
||||
|
||||
const hasMutatedAuthData = (authData, userAuthData) => {
|
||||
if (!userAuthData) return { hasMutatedAuthData: true, mutatedAuthData: authData };
|
||||
const mutatedAuthData = {};
|
||||
Object.keys(authData).forEach(provider => {
|
||||
// Anonymous provider is not handled this way
|
||||
if (provider === 'anonymous') return;
|
||||
const providerData = authData[provider];
|
||||
const userProviderAuthData = userAuthData[provider];
|
||||
if (!isDeepStrictEqual(providerData, userProviderAuthData)) {
|
||||
mutatedAuthData[provider] = providerData;
|
||||
}
|
||||
});
|
||||
const hasMutatedAuthData = Object.keys(mutatedAuthData).length !== 0;
|
||||
return { hasMutatedAuthData, mutatedAuthData };
|
||||
};
|
||||
|
||||
const checkIfUserHasProvidedConfiguredProvidersForLogin = (
|
||||
authData = {},
|
||||
userAuthData = {},
|
||||
config
|
||||
) => {
|
||||
const savedUserProviders = Object.keys(userAuthData).map(provider => ({
|
||||
name: provider,
|
||||
adapter: config.authDataManager.getValidatorForProvider(provider).adapter,
|
||||
}));
|
||||
|
||||
const hasProvidedASoloProvider = savedUserProviders.some(
|
||||
provider =>
|
||||
provider && provider.adapter && provider.adapter.policy === 'solo' && authData[provider.name]
|
||||
);
|
||||
|
||||
// Solo providers can be considered as safe, so we do not have to check if the user needs
|
||||
// to provide an additional provider to login. An auth adapter with "solo" (like webauthn) means
|
||||
// no "additional" auth needs to be provided to login (like OTP, MFA)
|
||||
if (hasProvidedASoloProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const additionProvidersNotFound = [];
|
||||
const hasProvidedAtLeastOneAdditionalProvider = savedUserProviders.some(provider => {
|
||||
if (provider && provider.adapter && provider.adapter.policy === 'additional') {
|
||||
if (authData[provider.name]) {
|
||||
return true;
|
||||
} else {
|
||||
// Push missing provider for error message
|
||||
additionProvidersNotFound.push(provider.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (hasProvidedAtLeastOneAdditionalProvider || !additionProvidersNotFound.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OTHER_CAUSE,
|
||||
`Missing additional authData ${additionProvidersNotFound.join(',')}`
|
||||
);
|
||||
};
|
||||
|
||||
// Validate each authData step-by-step and return the provider responses
|
||||
const handleAuthDataValidation = async (authData, req, foundUser) => {
|
||||
let user;
|
||||
if (foundUser) {
|
||||
user = Parse.User.fromJSON({ className: '_User', ...foundUser });
|
||||
// Find user by session and current objectId; only pass user if it's the current user or master key is provided
|
||||
} else if (
|
||||
(req.auth &&
|
||||
req.auth.user &&
|
||||
typeof req.getUserId === 'function' &&
|
||||
req.getUserId() === req.auth.user.id) ||
|
||||
(req.auth && req.auth.isMaster && typeof req.getUserId === 'function' && req.getUserId())
|
||||
) {
|
||||
user = new Parse.User();
|
||||
user.id = req.auth.isMaster ? req.getUserId() : req.auth.user.id;
|
||||
await user.fetch({ useMasterKey: true });
|
||||
}
|
||||
|
||||
const { originalObject, updatedObject } = req.buildParseObjects();
|
||||
const requestObject = getRequestObject(
|
||||
undefined,
|
||||
req.auth,
|
||||
updatedObject,
|
||||
originalObject || user,
|
||||
req.config
|
||||
);
|
||||
// Perform validation as step-by-step pipeline for better error consistency
|
||||
// and also to avoid to trigger a provider (like OTP SMS) if another one fails
|
||||
const acc = { authData: {}, authDataResponse: {} };
|
||||
const authKeys = Object.keys(authData).sort();
|
||||
for (const provider of authKeys) {
|
||||
let method = '';
|
||||
try {
|
||||
if (authData[provider] === null) {
|
||||
acc.authData[provider] = null;
|
||||
continue;
|
||||
}
|
||||
const { validator } = req.config.authDataManager.getValidatorForProvider(provider);
|
||||
const authProvider = (req.config.auth || {})[provider] || {};
|
||||
if (authProvider.enabled == null) {
|
||||
Deprecator.logRuntimeDeprecation({
|
||||
usage: `auth.${provider}`,
|
||||
solution: `auth.${provider}.enabled: true`,
|
||||
});
|
||||
}
|
||||
if (!validator || authProvider.enabled === false) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.UNSUPPORTED_SERVICE,
|
||||
'This authentication method is unsupported.'
|
||||
);
|
||||
}
|
||||
let validationResult = await validator(authData[provider], req, user, requestObject);
|
||||
method = validationResult && validationResult.method;
|
||||
requestObject.triggerName = method;
|
||||
if (validationResult && validationResult.validator) {
|
||||
validationResult = await validationResult.validator();
|
||||
}
|
||||
if (!validationResult) {
|
||||
acc.authData[provider] = authData[provider];
|
||||
continue;
|
||||
}
|
||||
if (!Object.keys(validationResult).length) {
|
||||
acc.authData[provider] = authData[provider];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (validationResult.response) {
|
||||
acc.authDataResponse[provider] = validationResult.response;
|
||||
}
|
||||
// Some auth providers after initialization will avoid to replace authData already stored
|
||||
if (!validationResult.doNotSave) {
|
||||
acc.authData[provider] = validationResult.save || authData[provider];
|
||||
}
|
||||
} catch (err) {
|
||||
const e = resolveError(err, {
|
||||
code: Parse.Error.SCRIPT_FAILED,
|
||||
message: 'Auth failed. Unknown error.',
|
||||
});
|
||||
const userString =
|
||||
req.auth && req.auth.user ? req.auth.user.id : req.data.objectId || undefined;
|
||||
logger.error(
|
||||
`Failed running auth step ${method} for ${provider} for user ${userString} with Error: ` +
|
||||
JSON.stringify(e),
|
||||
{
|
||||
authenticationStep: method,
|
||||
error: e,
|
||||
user: userString,
|
||||
provider,
|
||||
}
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
Auth,
|
||||
master,
|
||||
@@ -314,4 +497,8 @@ module.exports = {
|
||||
readOnly,
|
||||
getAuthForSessionToken,
|
||||
getAuthForLegacySessionToken,
|
||||
findUsersWithAuthData,
|
||||
hasMutatedAuthData,
|
||||
checkIfUserHasProvidedConfiguredProvidersForLogin,
|
||||
handleAuthDataValidation,
|
||||
};
|
||||
|
||||
@@ -81,6 +81,7 @@ export class Config {
|
||||
enforcePrivateUsers,
|
||||
schema,
|
||||
requestKeywordDenylist,
|
||||
allowExpiredAuthDataToken,
|
||||
}) {
|
||||
if (masterKey === readOnlyMasterKey) {
|
||||
throw new Error('masterKey and readOnlyMasterKey should be different');
|
||||
@@ -120,6 +121,7 @@ export class Config {
|
||||
this.validateSecurityOptions(security);
|
||||
this.validateSchemaOptions(schema);
|
||||
this.validateEnforcePrivateUsers(enforcePrivateUsers);
|
||||
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
|
||||
this.validateRequestKeywordDenylist(requestKeywordDenylist);
|
||||
}
|
||||
|
||||
@@ -137,6 +139,12 @@ export class Config {
|
||||
}
|
||||
}
|
||||
|
||||
static validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken) {
|
||||
if (typeof allowExpiredAuthDataToken !== 'boolean') {
|
||||
throw 'Parse Server option allowExpiredAuthDataToken must be a boolean.';
|
||||
}
|
||||
}
|
||||
|
||||
static validateSecurityOptions(security) {
|
||||
if (Object.prototype.toString.call(security) !== '[object Object]') {
|
||||
throw 'Parse Server option security must be an object.';
|
||||
|
||||
@@ -24,4 +24,5 @@ module.exports = [
|
||||
},
|
||||
{ optionKey: 'enforcePrivateUsers', changeNewDefault: 'true' },
|
||||
{ optionKey: 'allowClientClassCreation', changeNewDefault: 'false' },
|
||||
{ optionKey: 'allowExpiredAuthDataToken', changeNewDefault: 'false' },
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable indent */
|
||||
import {
|
||||
GraphQLID,
|
||||
GraphQLObjectType,
|
||||
@@ -140,11 +141,7 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla
|
||||
...fields,
|
||||
[field]: {
|
||||
description: `This is the object ${field}.`,
|
||||
type:
|
||||
(className === '_User' && (field === 'username' || field === 'password')) ||
|
||||
parseClass.fields[field].required
|
||||
? new GraphQLNonNull(type)
|
||||
: type,
|
||||
type: parseClass.fields[field].required ? new GraphQLNonNull(type) : type,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
@@ -352,6 +349,14 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla
|
||||
const parseObjectFields = {
|
||||
id: globalIdField(className, obj => obj.objectId),
|
||||
...defaultGraphQLTypes.PARSE_OBJECT_FIELDS,
|
||||
...(className === '_User'
|
||||
? {
|
||||
authDataResponse: {
|
||||
description: `auth provider response when triggered on signUp/logIn.`,
|
||||
type: defaultGraphQLTypes.OBJECT,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const outputFields = () => {
|
||||
return classOutputFields.reduce((fields, field) => {
|
||||
|
||||
@@ -41,7 +41,7 @@ const load = parseGraphQLSchema => {
|
||||
req: { config, auth, info },
|
||||
});
|
||||
|
||||
const { sessionToken, objectId } = await objectsMutations.createObject(
|
||||
const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject(
|
||||
'_User',
|
||||
parseFields,
|
||||
config,
|
||||
@@ -50,9 +50,15 @@ const load = parseGraphQLSchema => {
|
||||
);
|
||||
|
||||
context.info.sessionToken = sessionToken;
|
||||
|
||||
const viewer = await getUserFromSessionToken(
|
||||
context,
|
||||
mutationInfo,
|
||||
'viewer.user.',
|
||||
objectId
|
||||
);
|
||||
if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse;
|
||||
return {
|
||||
viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId),
|
||||
viewer,
|
||||
};
|
||||
} catch (e) {
|
||||
parseGraphQLSchema.handleError(e);
|
||||
@@ -111,7 +117,7 @@ const load = parseGraphQLSchema => {
|
||||
req: { config, auth, info },
|
||||
});
|
||||
|
||||
const { sessionToken, objectId } = await objectsMutations.createObject(
|
||||
const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject(
|
||||
'_User',
|
||||
{ ...parseFields, authData },
|
||||
config,
|
||||
@@ -120,9 +126,15 @@ const load = parseGraphQLSchema => {
|
||||
);
|
||||
|
||||
context.info.sessionToken = sessionToken;
|
||||
|
||||
const viewer = await getUserFromSessionToken(
|
||||
context,
|
||||
mutationInfo,
|
||||
'viewer.user.',
|
||||
objectId
|
||||
);
|
||||
if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse;
|
||||
return {
|
||||
viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId),
|
||||
viewer,
|
||||
};
|
||||
} catch (e) {
|
||||
parseGraphQLSchema.handleError(e);
|
||||
@@ -146,6 +158,10 @@ const load = parseGraphQLSchema => {
|
||||
description: 'This is the password used to log in the user.',
|
||||
type: new GraphQLNonNull(GraphQLString),
|
||||
},
|
||||
authData: {
|
||||
description: 'Auth data payload, needed if some required auth adapters are configured.',
|
||||
type: OBJECT,
|
||||
},
|
||||
},
|
||||
outputFields: {
|
||||
viewer: {
|
||||
@@ -155,14 +171,15 @@ const load = parseGraphQLSchema => {
|
||||
},
|
||||
mutateAndGetPayload: async (args, context, mutationInfo) => {
|
||||
try {
|
||||
const { username, password } = deepcopy(args);
|
||||
const { username, password, authData } = deepcopy(args);
|
||||
const { config, auth, info } = context;
|
||||
|
||||
const { sessionToken, objectId } = (
|
||||
const { sessionToken, objectId, authDataResponse } = (
|
||||
await usersRouter.handleLogIn({
|
||||
body: {
|
||||
username,
|
||||
password,
|
||||
authData,
|
||||
},
|
||||
query: {},
|
||||
config,
|
||||
@@ -173,8 +190,15 @@ const load = parseGraphQLSchema => {
|
||||
|
||||
context.info.sessionToken = sessionToken;
|
||||
|
||||
const viewer = await getUserFromSessionToken(
|
||||
context,
|
||||
mutationInfo,
|
||||
'viewer.user.',
|
||||
objectId
|
||||
);
|
||||
if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse;
|
||||
return {
|
||||
viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId),
|
||||
viewer,
|
||||
};
|
||||
} catch (e) {
|
||||
parseGraphQLSchema.handleError(e);
|
||||
@@ -355,6 +379,57 @@ const load = parseGraphQLSchema => {
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
const challengeMutation = mutationWithClientMutationId({
|
||||
name: 'Challenge',
|
||||
description:
|
||||
'The challenge mutation can be used to initiate an authentication challenge when an auth adapter needs it.',
|
||||
inputFields: {
|
||||
username: {
|
||||
description: 'This is the username used to log in the user.',
|
||||
type: GraphQLString,
|
||||
},
|
||||
password: {
|
||||
description: 'This is the password used to log in the user.',
|
||||
type: GraphQLString,
|
||||
},
|
||||
authData: {
|
||||
description:
|
||||
'Auth data allow to preidentify the user if the auth adapter needs preidentification.',
|
||||
type: OBJECT,
|
||||
},
|
||||
challengeData: {
|
||||
description:
|
||||
'Challenge data payload, can be used to post data to auth providers to auth providers if they need data for the response.',
|
||||
type: OBJECT,
|
||||
},
|
||||
},
|
||||
outputFields: {
|
||||
challengeData: {
|
||||
description: 'Challenge response from configured auth adapters.',
|
||||
type: OBJECT,
|
||||
},
|
||||
},
|
||||
mutateAndGetPayload: async (input, context) => {
|
||||
try {
|
||||
const { config, auth, info } = context;
|
||||
|
||||
const { response } = await usersRouter.handleChallenge({
|
||||
body: input,
|
||||
config,
|
||||
auth,
|
||||
info,
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
parseGraphQLSchema.handleError(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
parseGraphQLSchema.addGraphQLType(challengeMutation.args.input.type.ofType, true, true);
|
||||
parseGraphQLSchema.addGraphQLType(challengeMutation.type, true, true);
|
||||
parseGraphQLSchema.addGraphQLMutation('challenge', challengeMutation, true, true);
|
||||
};
|
||||
|
||||
export { load };
|
||||
|
||||
@@ -68,6 +68,13 @@ module.exports.ParseServerOptions = {
|
||||
action: parsers.booleanParser,
|
||||
default: false,
|
||||
},
|
||||
allowExpiredAuthDataToken: {
|
||||
env: 'PARSE_SERVER_ALLOW_EXPIRED_AUTH_DATA_TOKEN',
|
||||
help:
|
||||
'Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `true`.',
|
||||
action: parsers.booleanParser,
|
||||
default: true,
|
||||
},
|
||||
allowHeaders: {
|
||||
env: 'PARSE_SERVER_ALLOW_HEADERS',
|
||||
help: 'Add headers to Access-Control-Allow-Headers',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.
|
||||
* @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to true
|
||||
* @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId
|
||||
* @property {Boolean} allowExpiredAuthDataToken Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `true`.
|
||||
* @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers
|
||||
* @property {String} allowOrigin Sets the origin to Access-Control-Allow-Origin
|
||||
* @property {Adapter<AnalyticsAdapter>} analyticsAdapter Adapter module for the analytics
|
||||
|
||||
@@ -282,6 +282,9 @@ export interface ParseServerOptions {
|
||||
/* Set to true if new users should be created without public read and write access.
|
||||
:DEFAULT: false */
|
||||
enforcePrivateUsers: ?boolean;
|
||||
/* Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `true`.
|
||||
:DEFAULT: true */
|
||||
allowExpiredAuthDataToken: ?boolean;
|
||||
/* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns.
|
||||
:DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */
|
||||
requestKeywordDenylist: ?(RequestKeywordDenylist[]);
|
||||
|
||||
@@ -593,7 +593,7 @@ RestQuery.prototype.replaceDontSelect = function () {
|
||||
});
|
||||
};
|
||||
|
||||
const cleanResultAuthData = function (result) {
|
||||
RestQuery.prototype.cleanResultAuthData = function (result) {
|
||||
delete result.password;
|
||||
if (result.authData) {
|
||||
Object.keys(result.authData).forEach(provider => {
|
||||
@@ -662,7 +662,7 @@ RestQuery.prototype.runFind = function (options = {}) {
|
||||
.then(results => {
|
||||
if (this.className === '_User' && !findOptions.explain) {
|
||||
for (var result of results) {
|
||||
cleanResultAuthData(result);
|
||||
this.cleanResultAuthData(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
313
src/RestWrite.js
313
src/RestWrite.js
@@ -16,7 +16,6 @@ const util = require('util');
|
||||
import RestQuery from './RestQuery';
|
||||
import _ from 'lodash';
|
||||
import logger from './logger';
|
||||
import Deprecator from './Deprecator/Deprecator';
|
||||
import { requiredColumns } from './Controllers/SchemaController';
|
||||
|
||||
// query and data are both provided in REST API format. So data
|
||||
@@ -114,6 +113,9 @@ RestWrite.prototype.execute = function () {
|
||||
.then(() => {
|
||||
return this.runBeforeSaveTrigger();
|
||||
})
|
||||
.then(() => {
|
||||
return this.ensureUniqueAuthDataId();
|
||||
})
|
||||
.then(() => {
|
||||
return this.deleteEmailResetTokenIfNeeded();
|
||||
})
|
||||
@@ -149,6 +151,12 @@ RestWrite.prototype.execute = function () {
|
||||
return this.cleanUserAuthData();
|
||||
})
|
||||
.then(() => {
|
||||
// Append the authDataResponse if exists
|
||||
if (this.authDataResponse) {
|
||||
if (this.response && this.response.response) {
|
||||
this.response.response.authDataResponse = this.authDataResponse;
|
||||
}
|
||||
}
|
||||
return this.response;
|
||||
});
|
||||
};
|
||||
@@ -375,7 +383,11 @@ RestWrite.prototype.validateAuthData = function () {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.query && !this.data.authData) {
|
||||
const authData = this.data.authData;
|
||||
const hasUsernameAndPassword =
|
||||
typeof this.data.username === 'string' && typeof this.data.password === 'string';
|
||||
|
||||
if (!this.query && !authData) {
|
||||
if (typeof this.data.username !== 'string' || _.isEmpty(this.data.username)) {
|
||||
throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username');
|
||||
}
|
||||
@@ -385,10 +397,10 @@ RestWrite.prototype.validateAuthData = function () {
|
||||
}
|
||||
|
||||
if (
|
||||
(this.data.authData && !Object.keys(this.data.authData).length) ||
|
||||
(authData && !Object.keys(authData).length) ||
|
||||
!Object.prototype.hasOwnProperty.call(this.data, 'authData')
|
||||
) {
|
||||
// Handle saving authData to {} or if authData doesn't exist
|
||||
// Nothing to validate here
|
||||
return;
|
||||
} else if (Object.prototype.hasOwnProperty.call(this.data, 'authData') && !this.data.authData) {
|
||||
// Handle saving authData to null
|
||||
@@ -398,15 +410,14 @@ RestWrite.prototype.validateAuthData = function () {
|
||||
);
|
||||
}
|
||||
|
||||
var authData = this.data.authData;
|
||||
var providers = Object.keys(authData);
|
||||
if (providers.length > 0) {
|
||||
const canHandleAuthData = providers.reduce((canHandle, provider) => {
|
||||
const canHandleAuthData = providers.some(provider => {
|
||||
var providerAuthData = authData[provider];
|
||||
var hasToken = providerAuthData && providerAuthData.id;
|
||||
return canHandle && (hasToken || providerAuthData == null);
|
||||
}, true);
|
||||
if (canHandleAuthData) {
|
||||
return hasToken || providerAuthData === null;
|
||||
});
|
||||
if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) {
|
||||
return this.handleAuthData(authData);
|
||||
}
|
||||
}
|
||||
@@ -416,55 +427,6 @@ RestWrite.prototype.validateAuthData = function () {
|
||||
);
|
||||
};
|
||||
|
||||
RestWrite.prototype.handleAuthDataValidation = function (authData) {
|
||||
const validations = Object.keys(authData).map(provider => {
|
||||
if (authData[provider] === null) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const validateAuthData = this.config.authDataManager.getValidatorForProvider(provider);
|
||||
const authProvider = (this.config.auth || {})[provider] || {};
|
||||
if (authProvider.enabled == null) {
|
||||
Deprecator.logRuntimeDeprecation({
|
||||
usage: `auth.${provider}`,
|
||||
solution: `auth.${provider}.enabled: true`,
|
||||
});
|
||||
}
|
||||
if (!validateAuthData || authProvider.enabled === false) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.UNSUPPORTED_SERVICE,
|
||||
'This authentication method is unsupported.'
|
||||
);
|
||||
}
|
||||
return validateAuthData(authData[provider]);
|
||||
});
|
||||
return Promise.all(validations);
|
||||
};
|
||||
|
||||
RestWrite.prototype.findUsersWithAuthData = function (authData) {
|
||||
const providers = Object.keys(authData);
|
||||
const query = providers
|
||||
.reduce((memo, provider) => {
|
||||
if (!authData[provider]) {
|
||||
return memo;
|
||||
}
|
||||
const queryKey = `authData.${provider}.id`;
|
||||
const query = {};
|
||||
query[queryKey] = authData[provider].id;
|
||||
memo.push(query);
|
||||
return memo;
|
||||
}, [])
|
||||
.filter(q => {
|
||||
return typeof q !== 'undefined';
|
||||
});
|
||||
|
||||
let findPromise = Promise.resolve([]);
|
||||
if (query.length > 0) {
|
||||
findPromise = this.config.database.find(this.className, { $or: query }, {});
|
||||
}
|
||||
|
||||
return findPromise;
|
||||
};
|
||||
|
||||
RestWrite.prototype.filteredObjectsByACL = function (objects) {
|
||||
if (this.auth.isMaster) {
|
||||
return objects;
|
||||
@@ -478,106 +440,161 @@ RestWrite.prototype.filteredObjectsByACL = function (objects) {
|
||||
});
|
||||
};
|
||||
|
||||
RestWrite.prototype.handleAuthData = function (authData) {
|
||||
let results;
|
||||
return this.findUsersWithAuthData(authData).then(async r => {
|
||||
results = this.filteredObjectsByACL(r);
|
||||
RestWrite.prototype.getUserId = function () {
|
||||
if (this.query && this.query.objectId && this.className === '_User') {
|
||||
return this.query.objectId;
|
||||
} else if (this.auth && this.auth.user && this.auth.user.id) {
|
||||
return this.auth.user.id;
|
||||
}
|
||||
};
|
||||
|
||||
if (results.length == 1) {
|
||||
this.storage['authProvider'] = Object.keys(authData).join(',');
|
||||
// Developers are allowed to change authData via before save trigger
|
||||
// we need after before save to ensure that the developer
|
||||
// is not currently duplicating auth data ID
|
||||
RestWrite.prototype.ensureUniqueAuthDataId = async function () {
|
||||
if (this.className !== '_User' || !this.data.authData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userResult = results[0];
|
||||
const mutatedAuthData = {};
|
||||
Object.keys(authData).forEach(provider => {
|
||||
const providerData = authData[provider];
|
||||
const userAuthData = userResult.authData[provider];
|
||||
if (!_.isEqual(providerData, userAuthData)) {
|
||||
mutatedAuthData[provider] = providerData;
|
||||
}
|
||||
});
|
||||
const hasMutatedAuthData = Object.keys(mutatedAuthData).length !== 0;
|
||||
let userId;
|
||||
if (this.query && this.query.objectId) {
|
||||
userId = this.query.objectId;
|
||||
} else if (this.auth && this.auth.user && this.auth.user.id) {
|
||||
userId = this.auth.user.id;
|
||||
const hasAuthDataId = Object.keys(this.data.authData).some(
|
||||
key => this.data.authData[key] && this.data.authData[key].id
|
||||
);
|
||||
|
||||
if (!hasAuthDataId) return;
|
||||
|
||||
const r = await Auth.findUsersWithAuthData(this.config, this.data.authData);
|
||||
const results = this.filteredObjectsByACL(r);
|
||||
if (results.length > 1) {
|
||||
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
|
||||
}
|
||||
// use data.objectId in case of login time and found user during handle validateAuthData
|
||||
const userId = this.getUserId() || this.data.objectId;
|
||||
if (results.length === 1 && userId !== results[0].objectId) {
|
||||
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
|
||||
}
|
||||
};
|
||||
|
||||
RestWrite.prototype.handleAuthData = async function (authData) {
|
||||
const r = await Auth.findUsersWithAuthData(this.config, authData);
|
||||
const results = this.filteredObjectsByACL(r);
|
||||
|
||||
if (results.length > 1) {
|
||||
// To avoid https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5
|
||||
// Let's run some validation before throwing
|
||||
await Auth.handleAuthDataValidation(authData, this, results[0]);
|
||||
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
|
||||
}
|
||||
|
||||
// No user found with provided authData we need to validate
|
||||
if (!results.length) {
|
||||
const { authData: validatedAuthData, authDataResponse } = await Auth.handleAuthDataValidation(
|
||||
authData,
|
||||
this
|
||||
);
|
||||
this.authDataResponse = authDataResponse;
|
||||
// Replace current authData by the new validated one
|
||||
this.data.authData = validatedAuthData;
|
||||
return;
|
||||
}
|
||||
|
||||
// User found with provided authData
|
||||
if (results.length === 1) {
|
||||
const userId = this.getUserId();
|
||||
const userResult = results[0];
|
||||
// Prevent duplicate authData id
|
||||
if (userId && userId !== userResult.objectId) {
|
||||
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
|
||||
}
|
||||
|
||||
this.storage.authProvider = Object.keys(authData).join(',');
|
||||
|
||||
const { hasMutatedAuthData, mutatedAuthData } = Auth.hasMutatedAuthData(
|
||||
authData,
|
||||
userResult.authData
|
||||
);
|
||||
|
||||
const isCurrentUserLoggedOrMaster =
|
||||
(this.auth && this.auth.user && this.auth.user.id === userResult.objectId) ||
|
||||
this.auth.isMaster;
|
||||
|
||||
const isLogin = !userId;
|
||||
|
||||
if (isLogin || isCurrentUserLoggedOrMaster) {
|
||||
// no user making the call
|
||||
// OR the user making the call is the right one
|
||||
// Login with auth data
|
||||
delete results[0].password;
|
||||
|
||||
// need to set the objectId first otherwise location has trailing undefined
|
||||
this.data.objectId = userResult.objectId;
|
||||
|
||||
if (!this.query || !this.query.objectId) {
|
||||
this.response = {
|
||||
response: userResult,
|
||||
location: this.location(),
|
||||
};
|
||||
// Run beforeLogin hook before storing any updates
|
||||
// to authData on the db; changes to userResult
|
||||
// will be ignored.
|
||||
await this.runBeforeLoginTrigger(deepcopy(userResult));
|
||||
|
||||
// If we are in login operation via authData
|
||||
// we need to be sure that the user has provided
|
||||
// required authData
|
||||
Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(
|
||||
authData,
|
||||
userResult.authData,
|
||||
this.config
|
||||
);
|
||||
}
|
||||
if (!userId || userId === userResult.objectId) {
|
||||
// no user making the call
|
||||
// OR the user making the call is the right one
|
||||
// Login with auth data
|
||||
delete results[0].password;
|
||||
|
||||
// need to set the objectId first otherwise location has trailing undefined
|
||||
this.data.objectId = userResult.objectId;
|
||||
// Prevent validating if no mutated data detected on update
|
||||
if (!hasMutatedAuthData && isCurrentUserLoggedOrMaster) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.query || !this.query.objectId) {
|
||||
// this a login call, no userId passed
|
||||
this.response = {
|
||||
response: userResult,
|
||||
location: this.location(),
|
||||
};
|
||||
// Run beforeLogin hook before storing any updates
|
||||
// to authData on the db; changes to userResult
|
||||
// will be ignored.
|
||||
await this.runBeforeLoginTrigger(deepcopy(userResult));
|
||||
}
|
||||
// Force to validate all provided authData on login
|
||||
// on update only validate mutated ones
|
||||
if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) {
|
||||
const res = await Auth.handleAuthDataValidation(
|
||||
isLogin ? authData : mutatedAuthData,
|
||||
this,
|
||||
userResult
|
||||
);
|
||||
this.data.authData = res.authData;
|
||||
this.authDataResponse = res.authDataResponse;
|
||||
}
|
||||
|
||||
// If we didn't change the auth data, just keep going
|
||||
if (!hasMutatedAuthData) {
|
||||
return;
|
||||
}
|
||||
// We have authData that is updated on login
|
||||
// that can happen when token are refreshed,
|
||||
// We should update the token and let the user in
|
||||
// We should only check the mutated keys
|
||||
return this.handleAuthDataValidation(mutatedAuthData).then(async () => {
|
||||
// IF we have a response, we'll skip the database operation / beforeSave / afterSave etc...
|
||||
// we need to set it up there.
|
||||
// We are supposed to have a response only on LOGIN with authData, so we skip those
|
||||
// If we're not logging in, but just updating the current user, we can safely skip that part
|
||||
if (this.response) {
|
||||
// Assign the new authData in the response
|
||||
Object.keys(mutatedAuthData).forEach(provider => {
|
||||
this.response.response.authData[provider] = mutatedAuthData[provider];
|
||||
});
|
||||
|
||||
// Run the DB update directly, as 'master'
|
||||
// Just update the authData part
|
||||
// Then we're good for the user, early exit of sorts
|
||||
return this.config.database.update(
|
||||
this.className,
|
||||
{ objectId: this.data.objectId },
|
||||
{ authData: mutatedAuthData },
|
||||
{}
|
||||
);
|
||||
}
|
||||
// IF we are in login we'll skip the database operation / beforeSave / afterSave etc...
|
||||
// we need to set it up there.
|
||||
// We are supposed to have a response only on LOGIN with authData, so we skip those
|
||||
// If we're not logging in, but just updating the current user, we can safely skip that part
|
||||
if (this.response) {
|
||||
// Assign the new authData in the response
|
||||
Object.keys(mutatedAuthData).forEach(provider => {
|
||||
this.response.response.authData[provider] = mutatedAuthData[provider];
|
||||
});
|
||||
} else if (userId) {
|
||||
// Trying to update auth data but users
|
||||
// are different
|
||||
if (userResult.objectId !== userId) {
|
||||
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
|
||||
}
|
||||
// No auth data was mutated, just keep going
|
||||
if (!hasMutatedAuthData) {
|
||||
return;
|
||||
|
||||
// Run the DB update directly, as 'master' only if authData contains some keys
|
||||
// authData could not contains keys after validation if the authAdapter
|
||||
// uses the `doNotSave` option. Just update the authData part
|
||||
// Then we're good for the user, early exit of sorts
|
||||
if (Object.keys(this.data.authData).length) {
|
||||
await this.config.database.update(
|
||||
this.className,
|
||||
{ objectId: this.data.objectId },
|
||||
{ authData: this.data.authData },
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.handleAuthDataValidation(authData).then(() => {
|
||||
if (results.length > 1) {
|
||||
// More than 1 user with the passed id's
|
||||
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// The non-third-party parts of User transformation
|
||||
RestWrite.prototype.transformUser = function () {
|
||||
var promise = Promise.resolve();
|
||||
|
||||
if (this.className !== '_User') {
|
||||
return promise;
|
||||
}
|
||||
@@ -848,7 +865,7 @@ RestWrite.prototype.createSessionTokenIfNeeded = function () {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!this.storage['authProvider'] && // signup call, with
|
||||
!this.storage.authProvider && // signup call, with
|
||||
this.config.preventLoginWithUnverifiedEmail && // no login without verification
|
||||
this.config.verifyUserEmails
|
||||
) {
|
||||
@@ -865,15 +882,15 @@ RestWrite.prototype.createSessionToken = async function () {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.storage['authProvider'] == null && this.data.authData) {
|
||||
this.storage['authProvider'] = Object.keys(this.data.authData).join(',');
|
||||
if (this.storage.authProvider == null && this.data.authData) {
|
||||
this.storage.authProvider = Object.keys(this.data.authData).join(',');
|
||||
}
|
||||
|
||||
const { sessionData, createSession } = RestWrite.createSession(this.config, {
|
||||
userId: this.objectId(),
|
||||
createdWith: {
|
||||
action: this.storage['authProvider'] ? 'login' : 'signup',
|
||||
authProvider: this.storage['authProvider'] || 'password',
|
||||
action: this.storage.authProvider ? 'login' : 'signup',
|
||||
authProvider: this.storage.authProvider || 'password',
|
||||
},
|
||||
installationId: this.auth.installationId,
|
||||
});
|
||||
|
||||
@@ -7,9 +7,15 @@ import ClassesRouter from './ClassesRouter';
|
||||
import rest from '../rest';
|
||||
import Auth from '../Auth';
|
||||
import passwordCrypto from '../password';
|
||||
import { maybeRunTrigger, Types as TriggerTypes } from '../triggers';
|
||||
import {
|
||||
maybeRunTrigger,
|
||||
Types as TriggerTypes,
|
||||
getRequestObject,
|
||||
resolveError,
|
||||
} from '../triggers';
|
||||
import { promiseEnsureIdempotency } from '../middlewares';
|
||||
import RestWrite from '../RestWrite';
|
||||
import { logger } from '../logger';
|
||||
|
||||
export class UsersRouter extends ClassesRouter {
|
||||
className() {
|
||||
@@ -174,7 +180,6 @@ export class UsersRouter extends ClassesRouter {
|
||||
|
||||
// Remove hidden properties.
|
||||
UsersRouter.removeHiddenProperties(user);
|
||||
|
||||
return { response: user };
|
||||
}
|
||||
});
|
||||
@@ -182,6 +187,30 @@ export class UsersRouter extends ClassesRouter {
|
||||
|
||||
async handleLogIn(req) {
|
||||
const user = await this._authenticateUserFromRequest(req);
|
||||
const authData = req.body && req.body.authData;
|
||||
// Check if user has provided their required auth providers
|
||||
Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(authData, user.authData, req.config);
|
||||
|
||||
let authDataResponse;
|
||||
let validatedAuthData;
|
||||
if (authData) {
|
||||
const res = await Auth.handleAuthDataValidation(
|
||||
authData,
|
||||
new RestWrite(
|
||||
req.config,
|
||||
req.auth,
|
||||
'_User',
|
||||
{ objectId: user.objectId },
|
||||
req.body,
|
||||
user,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
),
|
||||
user
|
||||
);
|
||||
authDataResponse = res.authDataResponse;
|
||||
validatedAuthData = res.authData;
|
||||
}
|
||||
|
||||
// handle password expiry policy
|
||||
if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) {
|
||||
@@ -228,6 +257,16 @@ export class UsersRouter extends ClassesRouter {
|
||||
req.config
|
||||
);
|
||||
|
||||
// If we have some new validated authData update directly
|
||||
if (validatedAuthData && Object.keys(validatedAuthData).length) {
|
||||
await req.config.database.update(
|
||||
'_User',
|
||||
{ objectId: user.objectId },
|
||||
{ authData: validatedAuthData },
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
const { sessionData, createSession } = RestWrite.createSession(req.config, {
|
||||
userId: user.objectId,
|
||||
createdWith: {
|
||||
@@ -250,6 +289,10 @@ export class UsersRouter extends ClassesRouter {
|
||||
req.config
|
||||
);
|
||||
|
||||
if (authDataResponse) {
|
||||
user.authDataResponse = authDataResponse;
|
||||
}
|
||||
|
||||
return { response: user };
|
||||
}
|
||||
|
||||
@@ -453,6 +496,127 @@ export class UsersRouter extends ClassesRouter {
|
||||
});
|
||||
}
|
||||
|
||||
async handleChallenge(req) {
|
||||
const { username, email, password, authData, challengeData } = req.body;
|
||||
|
||||
// if username or email provided with password try to authenticate the user by username
|
||||
let user;
|
||||
if (username || email) {
|
||||
if (!password) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OTHER_CAUSE,
|
||||
'You provided username or email, you need to also provide password.'
|
||||
);
|
||||
}
|
||||
user = await this._authenticateUserFromRequest(req);
|
||||
}
|
||||
|
||||
if (!challengeData) {
|
||||
throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Nothing to challenge.');
|
||||
}
|
||||
|
||||
if (typeof challengeData !== 'object') {
|
||||
throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'challengeData should be an object.');
|
||||
}
|
||||
|
||||
let request;
|
||||
let parseUser;
|
||||
|
||||
// Try to find user by authData
|
||||
if (authData) {
|
||||
if (typeof authData !== 'object') {
|
||||
throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'authData should be an object.');
|
||||
}
|
||||
if (user) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OTHER_CAUSE,
|
||||
'You cannot provide username/email and authData, only use one identification method.'
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(authData).filter(key => authData[key].id).length > 1) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OTHER_CAUSE,
|
||||
'You cannot provide more than one authData provider with an id.'
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Auth.findUsersWithAuthData(req.config, authData);
|
||||
|
||||
try {
|
||||
if (!results[0] || results.length > 1) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'User not found.');
|
||||
}
|
||||
// Find the provider used to find the user
|
||||
const provider = Object.keys(authData).find(key => authData[key].id);
|
||||
|
||||
parseUser = Parse.User.fromJSON({ className: '_User', ...results[0] });
|
||||
request = getRequestObject(undefined, req.auth, parseUser, parseUser, req.config);
|
||||
request.isChallenge = true;
|
||||
// Validate authData used to identify the user to avoid brute-force attack on `id`
|
||||
const { validator } = req.config.authDataManager.getValidatorForProvider(provider);
|
||||
const validatorResponse = await validator(authData[provider], req, parseUser, request);
|
||||
if (validatorResponse && validatorResponse.validator) {
|
||||
await validatorResponse.validator();
|
||||
}
|
||||
} catch (e) {
|
||||
// Rewrite the error to avoid guess id attack
|
||||
logger.error(e);
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'User not found.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!parseUser) {
|
||||
parseUser = user ? Parse.User.fromJSON({ className: '_User', ...user }) : undefined;
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
request = getRequestObject(undefined, req.auth, parseUser, parseUser, req.config);
|
||||
request.isChallenge = true;
|
||||
}
|
||||
const acc = {};
|
||||
// Execute challenge step-by-step with consistent order for better error feedback
|
||||
// and to avoid to trigger others challenges if one of them fails
|
||||
for (const provider of Object.keys(challengeData).sort()) {
|
||||
try {
|
||||
const authAdapter = req.config.authDataManager.getValidatorForProvider(provider);
|
||||
if (!authAdapter) {
|
||||
continue;
|
||||
}
|
||||
const {
|
||||
adapter: { challenge },
|
||||
} = authAdapter;
|
||||
if (typeof challenge === 'function') {
|
||||
const providerChallengeResponse = await challenge(
|
||||
challengeData[provider],
|
||||
authData && authData[provider],
|
||||
req.config.auth[provider],
|
||||
request
|
||||
);
|
||||
acc[provider] = providerChallengeResponse || true;
|
||||
}
|
||||
} catch (err) {
|
||||
const e = resolveError(err, {
|
||||
code: Parse.Error.SCRIPT_FAILED,
|
||||
message: 'Challenge failed. Unknown error.',
|
||||
});
|
||||
const userString = req.auth && req.auth.user ? req.auth.user.id : undefined;
|
||||
logger.error(
|
||||
`Failed running auth step challenge for ${provider} for user ${userString} with Error: ` +
|
||||
JSON.stringify(e),
|
||||
{
|
||||
authenticationStep: 'challenge',
|
||||
error: e,
|
||||
user: userString,
|
||||
provider,
|
||||
}
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return { response: { challengeData: acc } };
|
||||
}
|
||||
|
||||
mountRoutes() {
|
||||
this.route('GET', '/users', req => {
|
||||
return this.handleFind(req);
|
||||
@@ -493,6 +657,9 @@ export class UsersRouter extends ClassesRouter {
|
||||
this.route('GET', '/verifyPassword', req => {
|
||||
return this.handleVerifyPassword(req);
|
||||
});
|
||||
this.route('POST', '/challenge', req => {
|
||||
return this.handleChallenge(req);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -728,6 +728,7 @@ module.exports = ParseCloud;
|
||||
* @interface Parse.Cloud.TriggerRequest
|
||||
* @property {String} installationId If set, the installationId triggering the request.
|
||||
* @property {Boolean} master If true, means the master key was used.
|
||||
* @property {Boolean} isChallenge If true, means the current request is originally triggered by an auth challenge.
|
||||
* @property {Parse.User} user If set, the user that made the request.
|
||||
* @property {Parse.Object} object The object triggering the hook.
|
||||
* @property {String} ip The IP address of the client making the request.
|
||||
|
||||
Reference in New Issue
Block a user