Added field selector and defaultFieldSelector to many user queries

This commit is contained in:
Chris Morison
2019-12-16 11:51:44 +12:00
parent 21c1ca052d
commit 2ddec43bef
5 changed files with 174 additions and 33 deletions

View File

@@ -77,13 +77,22 @@ export class AccountsCommon {
throw new Error("userId method not implemented");
}
// merge the defaultFieldSelector with an existing options object
_addDefaultFieldSelector(options = {}) {
return options.fields || !this._options.defaultFieldSelector ? options : {
...options,
fields: this._options.defaultFieldSelector,
};
}
/**
* @summary Get the current user record, or `null` if no user is logged in. A reactive data source.
* @locus Anywhere
* @param {Object} [options] `options` parameter to be passed to `Meteor.user.findOne(selector, options)`. Can be used to limit the returned fields.
*/
user() {
user(options) {
const userId = this.userId();
return userId ? this.users.findOne(userId) : null;
return userId ? this.users.findOne(userId, this._addDefaultFieldSelector(options)) : null;
}
// Set up config for the accounts system. Call this on both the client
@@ -303,8 +312,9 @@ Meteor.userId = () => Accounts.userId();
* @summary Get the current user record, or `null` if no user is logged in. A reactive data source.
* @locus Anywhere but publish functions
* @importFromPackage meteor
* @param {Object} [options] `options` parameter to be passed to `Meteor.user.findOne(selector, options)`. Can be used to limit the returned fields.
*/
Meteor.user = () => Accounts.user();
Meteor.user = (options) => Accounts.user(options);
// how long (in days) until a login token expires
const DEFAULT_LOGIN_EXPIRATION_DAYS = 90;

View File

@@ -1212,7 +1212,7 @@ export class AccountsServer extends AccountsCommon {
selector[serviceIdKey] = serviceData.id;
}
let user = this.users.findOne(selector);
let user = this.users.findOne(selector, Accounts._addDefaultFieldSelector());
// When creating a new user we pass through all options. When updating an
// existing user, by default we only process/pass through the serviceData

View File

@@ -517,6 +517,40 @@ Tinytest.add(
}
);
Tinytest.add(
'accounts - MeteorUser() obeys options.defaultFieldSelector',
test => {
const ignoreFieldName = "bigArray";
const userId = Accounts.insertUserDoc({}, { username: Random.id(), [ignoreFieldName]: [1] });
const stampedToken = Accounts._generateStampedLoginToken();
Accounts._insertLoginToken(userId, stampedToken);
const options = Accounts._options;
// stub Meteor.userId() so it works outside methods and returns the correct user:
const origAccountsUserId = Accounts.userId;
Accounts.userId = () => userId;
Accounts._options = {};
// test the field is included by default
let user = Meteor.user();
test.isNotUndefined(user[ignoreFieldName], 'included by default');
// test the field is excluded
Accounts.config({defaultFieldSelector: {[ignoreFieldName]: 0}});
user = Meteor.user();
test.isUndefined(user[ignoreFieldName], 'excluded');
// test the field can still be retrieved if required
user = Meteor.user({fields: {[ignoreFieldName]: 1}});
test.isNotUndefined(user[ignoreFieldName], 'field can be retrieved');
test.isUndefined(user.username, 'field selector works');
Accounts._options = options;
Accounts.userId = origAccountsUserId;
}
);
Tinytest.add(
'accounts - verify onExternalLogin hook can update oauth user profiles',
test => {
@@ -527,16 +561,24 @@ Tinytest.add(
'facebook',
{ id: facebookId },
{ profile: { foo: 1 } },
).id;
).userId;
const ignoreFieldName = "bigArray";
const c = Meteor.users.update(uid1, {$set: {[ignoreFieldName]: [1]}});
let users =
Meteor.users.find({ 'services.facebook.id': facebookId }).fetch();
test.length(users, 1);
test.equal(users[0].profile.foo, 1);
test.isNotUndefined(users[0][ignoreFieldName], 'ignoreField - before limit fields');
// Verify user profile data can be modified using the onExternalLogin
// hook, for existing users.
Accounts.onExternalLogin((options) => {
// Also verify that the user object is filtered by _options.defaultFieldSelector
const accountsOptions = Accounts._options;
Accounts._options = {};
Accounts.config({defaultFieldSelector: {[ignoreFieldName]: 0}});
Accounts.onExternalLogin((options, user) => {
options.profile.foo = 2;
test.isUndefined(users[ignoreFieldName], 'ignoreField - after limit fields');
return options;
});
Accounts.updateOrCreateUserFromExternalService(
@@ -544,9 +586,11 @@ Tinytest.add(
{ id: facebookId },
{ profile: { foo: 1 } },
);
// test.isUndefined(users[0][ignoreFieldName], 'ignoreField - fields limited');
users = Meteor.users.find({ 'services.facebook.id': facebookId }).fetch();
test.length(users, 1);
test.equal(users[0].profile.foo, 2);
test.isNotUndefined(users[0][ignoreFieldName], 'ignoreField - still there');
// Verify user profile data can be modified using the onExternalLogin
// hook, for new users.
@@ -555,7 +599,7 @@ Tinytest.add(
'facebook',
{ id: facebookId },
{ profile: { foo: 3 } },
).id;
).userId;
users = Meteor.users.find({ 'services.facebook.id': facebookId }).fetch();
test.length(users, 1);
test.equal(users[0].profile.foo, 2);
@@ -564,5 +608,6 @@ Tinytest.add(
Meteor.users.remove(uid1);
Meteor.users.remove(uid2);
Accounts._onExternalLoginHook = null;
Accounts._options = accountsOptions;
}
);

View File

@@ -5,7 +5,7 @@ const bcryptHash = Meteor.wrapAsync(bcrypt.hash);
const bcryptCompare = Meteor.wrapAsync(bcrypt.compare);
// Utility for grabbing user
const getUserById = id => Meteor.users.findOne(id);
const getUserById = (id, options) => Meteor.users.findOne(id, Accounts._addDefaultFieldSelector(options));
// User records have a 'services.password.bcrypt' field on them to hold
// their hashed passwords (unless they have a 'services.password.srp'
@@ -73,6 +73,9 @@ const getRoundsFromBcryptHash = hash => {
// properties `digest` and `algorithm` (in which case we bcrypt
// `password.digest`).
//
// The user parameter needs at least user._id and user.services
Accounts._checkPasswordUserFields = {_id: 1, services: 1},
//
Accounts._checkPassword = (user, password) => {
const result = {
userId: user._id
@@ -120,12 +123,14 @@ const handleError = (msg, throwError = true) => {
/// LOGIN
///
Accounts._findUserByQuery = query => {
Accounts._findUserByQuery = (query, options) => {
let user = null;
if (query.id) {
user = getUserById(query.id);
// default field selector is added within getUserById()
user = getUserById(query.id, options);
} else {
options = Accounts._addDefaultFieldSelector(options);
let fieldName;
let fieldValue;
if (query.username) {
@@ -139,11 +144,11 @@ Accounts._findUserByQuery = query => {
}
let selector = {};
selector[fieldName] = fieldValue;
user = Meteor.users.findOne(selector);
user = Meteor.users.findOne(selector, options);
// If user is not found, try a case insensitive lookup
if (!user) {
selector = selectorForFastCaseInsensitiveLookup(fieldName, fieldValue);
const candidateUsers = Meteor.users.find(selector).fetch();
const candidateUsers = Meteor.users.find(selector, options).fetch();
// No match if multiple candidates are found
if (candidateUsers.length === 1) {
user = candidateUsers[0];
@@ -161,11 +166,12 @@ Accounts._findUserByQuery = query => {
* insensitive search, it returns null.
* @locus Server
* @param {String} username The username to look for
* @param {Object} [options] `options` parameter to be passed to `Meteor.user.findOne(selector, options)`. Can be used to limit the returned fields.
* @returns {Object} A user if found, else null
* @importFromPackage accounts-base
*/
Accounts.findUserByUsername =
username => Accounts._findUserByQuery({ username });
(username, options) => Accounts._findUserByQuery({ username }, options);
/**
* @summary Finds the user with the specified email.
@@ -174,10 +180,12 @@ Accounts.findUserByUsername =
* insensitive search, it returns null.
* @locus Server
* @param {String} email The email address to look for
* @param {Object} [options] `options` parameter to be passed to `Meteor.user.findOne(selector, options)`. Can be used to limit the returned fields.
* @returns {Object} A user if found, else null
* @importFromPackage accounts-base
*/
Accounts.findUserByEmail = email => Accounts._findUserByQuery({ email });
Accounts.findUserByEmail =
(email, options) => Accounts._findUserByQuery({ email }, options);
// Generates a MongoDB selector that can be used to perform a fast case
// insensitive lookup for the given fieldName and string. Since MongoDB does
@@ -230,7 +238,13 @@ const checkForCaseInsensitiveDuplicates = (fieldName, displayName, fieldValue, o
if (fieldValue && !skipCheck) {
const matchedUsers = Meteor.users.find(
selectorForFastCaseInsensitiveLookup(fieldName, fieldValue)).fetch();
selectorForFastCaseInsensitiveLookup(fieldName, fieldValue),
{
fields: {_id: 1},
// we only need a maximum of 2 users for the logic below to work
limit: 2,
}
).fetch();
if (matchedUsers.length > 0 &&
// If we don't have a userId yet, any match we find is a duplicate
@@ -289,7 +303,10 @@ Accounts.registerLoginHandler("password", options => {
});
const user = Accounts._findUserByQuery(options.user);
const user = Accounts._findUserByQuery(options.user, {fields: {
services: 1,
...Accounts._checkPasswordUserFields,
}});
if (!user) {
handleError("User not found");
}
@@ -358,7 +375,10 @@ Accounts.registerLoginHandler("password", options => {
password: passwordValidator
});
const user = Accounts._findUserByQuery(options.user);
const user = Accounts._findUserByQuery(options.user, {fields: {
services: 1,
...Accounts._checkPasswordUserFields,
}});
if (!user) {
handleError("User not found");
}
@@ -419,7 +439,9 @@ Accounts.setUsername = (userId, newUsername) => {
check(userId, NonEmptyString);
check(newUsername, NonEmptyString);
const user = getUserById(userId);
const user = getUserById(userId, {fields: {
username: 1,
}});
if (!user) {
handleError("User not found");
}
@@ -465,7 +487,10 @@ Meteor.methods({changePassword: function (oldPassword, newPassword) {
throw new Meteor.Error(401, "Must be logged in");
}
const user = getUserById(this.userId);
const user = getUserById(this.userId, {fields: {
services: 1,
...Accounts._checkPasswordUserFields,
}});
if (!user) {
handleError("User not found");
}
@@ -523,7 +548,7 @@ Meteor.methods({changePassword: function (oldPassword, newPassword) {
Accounts.setPassword = (userId, newPlaintextPassword, options) => {
options = { logout: true , ...options };
const user = getUserById(userId);
const user = getUserById(userId, {fields: {_id: 1}});
if (!user) {
throw new Meteor.Error(403, "User not found");
}
@@ -556,7 +581,7 @@ const pluckAddresses = (emails = []) => emails.map(email => email.address);
Meteor.methods({forgotPassword: options => {
check(options, {email: String});
const user = Accounts.findUserByEmail(options.email);
const user = Accounts.findUserByEmail(options.email, {fields: {emails: 1}});
if (!user) {
handleError("User not found");
}
@@ -581,6 +606,8 @@ Meteor.methods({forgotPassword: options => {
*/
Accounts.generateResetToken = (userId, email, reason, extraTokenData) => {
// Make sure the user exists, and email is one of their addresses.
// Don't limit the fields in the user object since the user is returned
// by the function and some other fields might be used elsewhere.
const user = getUserById(userId);
if (!user) {
handleError("Can't find user");
@@ -638,6 +665,8 @@ Accounts.generateResetToken = (userId, email, reason, extraTokenData) => {
*/
Accounts.generateVerificationToken = (userId, email, extraTokenData) => {
// Make sure the user exists, and email is one of their addresses.
// Don't limit the fields in the user object since the user is returned
// by the function and some other fields might be used elsewhere.
const user = getUserById(userId);
if (!user) {
handleError("Can't find user");
@@ -782,8 +811,13 @@ Meteor.methods({resetPassword: function (...args) {
check(token, String);
check(newPassword, passwordValidator);
const user = Meteor.users.findOne({
"services.password.reset.token": token});
const user = Meteor.users.findOne(
{"services.password.reset.token": token},
{fields: {
services: 1,
emails: 1,
}}
);
if (!user) {
throw new Meteor.Error(403, "Token expired");
}
@@ -889,7 +923,12 @@ Meteor.methods({verifyEmail: function (...args) {
check(token, String);
const user = Meteor.users.findOne(
{'services.email.verificationTokens.token': token});
{'services.email.verificationTokens.token': token},
{fields: {
services: 1,
emails: 1,
}}
);
if (!user)
throw new Meteor.Error(403, "Verify email link expired");
@@ -948,7 +987,7 @@ Accounts.addEmail = (userId, newEmail, verified) => {
verified = false;
}
const user = getUserById(userId);
const user = getUserById(userId, {fields: {emails: 1}});
if (!user)
throw new Meteor.Error(403, "User not found");
@@ -1030,7 +1069,7 @@ Accounts.removeEmail = (userId, email) => {
check(userId, NonEmptyString);
check(email, NonEmptyString);
const user = getUserById(userId);
const user = getUserById(userId, {fields: {_id: 1}});
if (!user)
throw new Meteor.Error(403, "User not found");
@@ -1153,4 +1192,4 @@ Accounts.createUser = (options, callback) => {
Meteor.users._ensureIndex('services.email.verificationTokens.token',
{unique: 1, sparse: 1});
Meteor.users._ensureIndex('services.password.reset.token',
{unique: 1, sparse: 1});
{unique: 1, sparse: 1});

View File

@@ -1700,10 +1700,12 @@ if (Meteor.isServer) (() => {
)
// We should be able to change the username
Tinytest.add("passwords - change username", test => {
Tinytest.add("passwords - change username & findUserByUsername", test => {
const username = Random.id();
const ignoreFieldName = "profile";
const userId = Accounts.createUser({
username: username
username,
[ignoreFieldName]: {name: 'foo'},
});
test.isTrue(userId);
@@ -1714,7 +1716,27 @@ if (Meteor.isServer) (() => {
test.equal(Accounts._findUserByQuery({id: userId}).username, newUsername);
// Test findUserByUsername as well while we're here
test.equal(Accounts.findUserByUsername(newUsername)._id, userId);
let user = Accounts.findUserByUsername(newUsername);
test.equal(user._id, userId, 'userId - ignore');
test.isNotUndefined(user[ignoreFieldName], 'field - no ignore');
// Test default field selector
const options = Accounts._options;
Accounts._options = {defaultFieldSelector: {[ignoreFieldName]: 0}};
user = Accounts.findUserByUsername(newUsername);
test.equal(user.username, newUsername, 'username - default ignore');
test.isUndefined(user[ignoreFieldName], 'field - default ignore');
// Test default field selector over-ride
user = Accounts.findUserByUsername(newUsername, {
fields: {
[ignoreFieldName]: 1
}
});
test.isUndefined(user.username, 'username - override');
test.isNotUndefined(user[ignoreFieldName], 'field - override');
Accounts._options = options;
});
Tinytest.add("passwords - change username to a new one only differing " +
@@ -1760,10 +1782,14 @@ if (Meteor.isServer) (() => {
user2OriginalUsername);
});
Tinytest.add("passwords - add email", test => {
Tinytest.add("passwords - add email & findUserByEmail", test => {
const origEmail = `${Random.id()}@turing.com`;
const username = Random.id();
const ignoreFieldName = "profile";
const userId = Accounts.createUser({
email: origEmail
email: origEmail,
username,
[ignoreFieldName]: {name: 'foo'},
});
const newEmail = `${Random.id()}@turing.com`;
@@ -1779,7 +1805,28 @@ if (Meteor.isServer) (() => {
]);
// Test findUserByEmail as well while we're here
test.equal(Accounts.findUserByEmail(origEmail)._id, userId);
let user = Accounts.findUserByEmail(origEmail);
test.equal(user._id, userId);
test.isNotUndefined(user[ignoreFieldName], 'field - no ignore');
// Test default field selector
const options = Accounts._options;
Accounts._options = {defaultFieldSelector: {[ignoreFieldName]: 0}};
user = Accounts.findUserByEmail(origEmail);
test.equal(user.username, username, 'username - default ignore');
test.isUndefined(user[ignoreFieldName], 'field - default ignore');
// Test default field selector over-ride
user = Accounts.findUserByEmail(origEmail, {
fields: {
[ignoreFieldName]: 1
}
});
test.equal(user._id, userId, 'userId - override');
test.isUndefined(user.username, 'username - override');
test.isNotUndefined(user[ignoreFieldName], 'field - override');
Accounts._options = options;
});
Tinytest.add("passwords - add email when the user has an existing email " +