packages/accounts-password/password_server.js
import { hash as bcryptHash, compare as bcryptCompare } from 'bcrypt';
import { Accounts } from "meteor/accounts-base";
// Utility for grabbing user
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.
//
// When the client sends a password to the server, it can either be a
// string (the plaintext password) or an object with keys 'digest' and
// 'algorithm' (must be "sha-256" for now). The Meteor client always sends
// password objects { digest: *, algorithm: "sha-256" }, but DDP clients
// that don't have access to SHA can just send plaintext passwords as
// strings.
//
// When the server receives a plaintext password as a string, it always
// hashes it with SHA256 before passing it into bcrypt. When the server
// receives a password as an object, it asserts that the algorithm is
// "sha-256" and then passes the digest to bcrypt.
Accounts._bcryptRounds = () => Accounts._options.bcryptRounds || 10;
// Given a 'password' from the client, extract the string that we should
// bcrypt. 'password' can be one of:
// - String (the plaintext password)
// - Object with 'digest' and 'algorithm' keys. 'algorithm' must be "sha-256".
//
const getPasswordString = password => {
if (typeof password === "string") {
password = SHA256(password);
} else { // 'password' is an object
if (password.algorithm !== "sha-256") {
throw new Error("Invalid password hash algorithm. " +
"Only 'sha-256' is allowed.");
}
password = password.digest;
}
return password;
};
// Use bcrypt to hash the password for storage in the database.
// `password` can be a string (in which case it will be run through
// SHA256 before bcrypt) or an object with properties `digest` and
// `algorithm` (in which case we bcrypt `password.digest`).
//
const hashPassword = async password => {
password = getPasswordString(password);
return await bcryptHash(password, Accounts._bcryptRounds());
};
// Extract the number of rounds used in the specified bcrypt hash.
const getRoundsFromBcryptHash = hash => {
let rounds;
if (hash) {
const hashSegments = hash.split('$');
if (hashSegments.length > 2) {
rounds = parseInt(hashSegments[2], 10);
}
}
return rounds;
};
// Check whether the provided password matches the bcrypt'ed password in
// the database user record. `password` can be a string (in which case
// it will be run through SHA256 before bcrypt) or an object with
// 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};
//
const checkPasswordAsync = async (user, password) => {
const result = {
userId: user._id
};
const formattedPassword = getPasswordString(password);
const hash = user.services.password.bcrypt;
const hashRounds = getRoundsFromBcryptHash(hash);
if (! await bcryptCompare(formattedPassword, hash)) {
result.error = Accounts._handleError("Incorrect password", false);
} else if (hash && Accounts._bcryptRounds() != hashRounds) {
// The password checks out, but the user's bcrypt hash needs to be updated.
Meteor.defer(async () => {
Meteor.users.update({ _id: user._id }, {
$set: {
'services.password.bcrypt':
await bcryptHash(formattedPassword, Accounts._bcryptRounds())
}
});
});
}
return result;
};
const checkPassword = (user, password) => {
return Promise.await(checkPasswordAsync(user, password));
};
Accounts._checkPassword = checkPassword;
Accounts._checkPasswordAsync = checkPasswordAsync;
///
/// LOGIN
///
/**
* @summary Finds the user with the specified username.
* First tries to match username case sensitively; if that fails, it
* tries case insensitively; but if more than one user matches the case
* insensitive search, it returns null.
* @locus Server
* @param {String} username The username to look for
* @param {Object} [options]
* @param {MongoFieldSpecifier} options.fields Dictionary of fields to return or exclude.
* @returns {Object} A user if found, else null
* @importFromPackage accounts-base
*/
Accounts.findUserByUsername =
(username, options) => Accounts._findUserByQuery({ username }, options);
/**
* @summary Finds the user with the specified email.
* First tries to match email case sensitively; if that fails, it
* tries case insensitively; but if more than one user matches the case
* insensitive search, it returns null.
* @locus Server
* @param {String} email The email address to look for
* @param {Object} [options]
* @param {MongoFieldSpecifier} options.fields Dictionary of fields to return or exclude.
* @returns {Object} A user if found, else null
* @importFromPackage accounts-base
*/
Accounts.findUserByEmail =
(email, options) => Accounts._findUserByQuery({ email }, options);
// XXX maybe this belongs in the check package
const NonEmptyString = Match.Where(x => {
check(x, String);
return x.length > 0;
});
const passwordValidator = Match.OneOf(
Match.Where(str => Match.test(str, String) && str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256), {
digest: Match.Where(str => Match.test(str, String) && str.length === 64),
algorithm: Match.OneOf('sha-256')
}
);
// Handler to login with a password.
//
// The Meteor client sets options.password to an object with keys
// 'digest' (set to SHA256(password)) and 'algorithm' ("sha-256").
//
// For other DDP clients which don't have access to SHA, the handler
// also accepts the plaintext password in options.password as a string.
//
// (It might be nice if servers could turn the plaintext password
// option off. Or maybe it should be opt-in, not opt-out?
// Accounts.config option?)
//
// Note that neither password option is secure without SSL.
//
Accounts.registerLoginHandler("password", async options => {
if (!options.password)
return undefined; // don't handle
check(options, {
user: Accounts._userQueryValidator,
password: passwordValidator,
code: Match.Optional(NonEmptyString),
});
const user = Accounts._findUserByQuery(options.user, {fields: {
services: 1,
...Accounts._checkPasswordUserFields,
}});
if (!user) {
Accounts._handleError("User not found");
}
if (!user.services || !user.services.password ||
!user.services.password.bcrypt) {
Accounts._handleError("User has no password set");
}
const result = await checkPasswordAsync(user, options.password);
// This method is added by the package accounts-2fa
// First the login is validated, then the code situation is checked
if (
!result.error &&
Accounts._check2faEnabled?.(user)
) {
if (!options.code) {
Accounts._handleError('2FA code must be informed', true, 'no-2fa-code');
}
if (
!Accounts._isTokenValid(
user.services.twoFactorAuthentication.secret,
options.code
)
) {
Accounts._handleError('Invalid 2FA code', true, 'invalid-2fa-code');
}
}
return result;
});
///
/// CHANGING
///
/**
* @summary Change a user's username. Use this instead of updating the
* database directly. The operation will fail if there is an existing user
* with a username only differing in case.
* @locus Server
* @param {String} userId The ID of the user to update.
* @param {String} newUsername A new username for the user.
* @importFromPackage accounts-base
*/
Accounts.setUsername = (userId, newUsername) => {
check(userId, NonEmptyString);
check(newUsername, NonEmptyString);
const user = getUserById(userId, {fields: {
username: 1,
}});
if (!user) {
Accounts._handleError("User not found");
}
const oldUsername = user.username;
// Perform a case insensitive check for duplicates before update
Accounts._checkForCaseInsensitiveDuplicates('username',
'Username', newUsername, user._id);
Meteor.users.update({_id: user._id}, {$set: {username: newUsername}});
// Perform another check after update, in case a matching user has been
// inserted in the meantime
try {
Accounts._checkForCaseInsensitiveDuplicates('username',
'Username', newUsername, user._id);
} catch (ex) {
// Undo update if the check fails
Meteor.users.update({_id: user._id}, {$set: {username: oldUsername}});
throw ex;
}
};
// Let the user change their own password if they know the old
// password. `oldPassword` and `newPassword` should be objects with keys
// `digest` and `algorithm` (representing the SHA256 of the password).
Meteor.methods({changePassword: async function (oldPassword, newPassword) {
check(oldPassword, passwordValidator);
check(newPassword, passwordValidator);
if (!this.userId) {
throw new Meteor.Error(401, "Must be logged in");
}
const user = getUserById(this.userId, {fields: {
services: 1,
...Accounts._checkPasswordUserFields,
}});
if (!user) {
Accounts._handleError("User not found");
}
if (!user.services || !user.services.password || !user.services.password.bcrypt) {
Accounts._handleError("User has no password set");
}
const result = await checkPasswordAsync(user, oldPassword);
if (result.error) {
throw result.error;
}
const hashed = await hashPassword(newPassword);
// It would be better if this removed ALL existing tokens and replaced
// the token for the current connection with a new one, but that would
// be tricky, so we'll settle for just replacing all tokens other than
// the one for the current connection.
const currentToken = Accounts._getLoginToken(this.connection.id);
Meteor.users.update(
{ _id: this.userId },
{
$set: { 'services.password.bcrypt': hashed },
$pull: {
'services.resume.loginTokens': { hashedToken: { $ne: currentToken } }
},
$unset: { 'services.password.reset': 1 }
}
);
return {passwordChanged: true};
}});
// Force change the users password.
/**
* @summary Forcibly change the password for a user.
* @locus Server
* @param {String} userId The id of the user to update.
* @param {String} newPassword A new password for the user.
* @param {Object} [options]
* @param {Object} options.logout Logout all current connections with this userId (default: true)
* @importFromPackage accounts-base
*/
Accounts.setPasswordAsync = async (userId, newPlaintextPassword, options) => {
check(userId, String);
check(newPlaintextPassword, Match.Where(str => Match.test(str, String) && str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256));
check(options, Match.Maybe({ logout: Boolean }));
options = { logout: true , ...options };
const user = getUserById(userId, {fields: {_id: 1}});
if (!user) {
throw new Meteor.Error(403, "User not found");
}
const update = {
$unset: {
'services.password.reset': 1
},
$set: {'services.password.bcrypt': await hashPassword(newPlaintextPassword)}
};
if (options.logout) {
update.$unset['services.resume.loginTokens'] = 1;
}
Meteor.users.update({_id: user._id}, update);
};
/**
* @summary Forcibly change the password for a user.
* @locus Server
* @param {String} userId The id of the user to update.
* @param {String} newPassword A new password for the user.
* @param {Object} [options]
* @param {Object} options.logout Logout all current connections with this userId (default: true)
* @importFromPackage accounts-base
*/
Accounts.setPassword = (userId, newPlaintextPassword, options) => {
return Promise.await(Accounts.setPasswordAsync(userId, newPlaintextPassword, options));
};
///
/// RESETTING VIA EMAIL
///
// Utility for plucking addresses from emails
const pluckAddresses = (emails = []) => emails.map(email => email.address);
// Method called by a user to request a password reset email. This is
// the start of the reset process.
Meteor.methods({forgotPassword: options => {
check(options, {email: String})
const user = Accounts.findUserByEmail(options.email, { fields: { emails: 1 } });
if (!user) {
Accounts._handleError("User not found");
}
const emails = pluckAddresses(user.emails);
const caseSensitiveEmail = emails.find(
email => email.toLowerCase() === options.email.toLowerCase()
);
Accounts.sendResetPasswordEmail(user._id, caseSensitiveEmail);
}});
/**
* @summary Generates a reset token and saves it into the database.
* @locus Server
* @param {String} userId The id of the user to generate the reset token for.
* @param {String} email Which address of the user to generate the reset token for. This address must be in the user's `emails` list. If `null`, defaults to the first email in the list.
* @param {String} reason `resetPassword` or `enrollAccount`.
* @param {Object} [extraTokenData] Optional additional data to be added into the token record.
* @returns {Object} Object with {email, user, token} values.
* @importFromPackage accounts-base
*/
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) {
Accounts._handleError("Can't find user");
}
// pick the first email if we weren't passed an email.
if (!email && user.emails && user.emails[0]) {
email = user.emails[0].address;
}
// make sure we have a valid email
if (!email ||
!(pluckAddresses(user.emails).includes(email))) {
Accounts._handleError("No such email for user.");
}
const token = Random.secret();
const tokenRecord = {
token,
email,
when: new Date()
};
if (reason === 'resetPassword') {
tokenRecord.reason = 'reset';
} else if (reason === 'enrollAccount') {
tokenRecord.reason = 'enroll';
} else if (reason) {
// fallback so that this function can be used for unknown reasons as well
tokenRecord.reason = reason;
}
if (extraTokenData) {
Object.assign(tokenRecord, extraTokenData);
}
// if this method is called from the enroll account work-flow then
// store the token record in 'services.password.enroll' db field
// else store the token record in in 'services.password.reset' db field
if(reason === 'enrollAccount') {
Meteor.users.update({_id: user._id}, {
$set : {
'services.password.enroll': tokenRecord
}
});
// before passing to template, update user object with new token
Meteor._ensure(user, 'services', 'password').enroll = tokenRecord;
} else {
Meteor.users.update({_id: user._id}, {
$set : {
'services.password.reset': tokenRecord
}
});
// before passing to template, update user object with new token
Meteor._ensure(user, 'services', 'password').reset = tokenRecord;
}
return {email, user, token};
};
/**
* @summary Generates an e-mail verification token and saves it into the database.
* @locus Server
* @param {String} userId The id of the user to generate the e-mail verification token for.
* @param {String} email Which address of the user to generate the e-mail verification token for. This address must be in the user's `emails` list. If `null`, defaults to the first unverified email in the list.
* @param {Object} [extraTokenData] Optional additional data to be added into the token record.
* @returns {Object} Object with {email, user, token} values.
* @importFromPackage accounts-base
*/
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) {
Accounts._handleError("Can't find user");
}
// pick the first unverified email if we weren't passed an email.
if (!email) {
const emailRecord = (user.emails || []).find(e => !e.verified);
email = (emailRecord || {}).address;
if (!email) {
Accounts._handleError("That user has no unverified email addresses.");
}
}
// make sure we have a valid email
if (!email ||
!(pluckAddresses(user.emails).includes(email))) {
Accounts._handleError("No such email for user.");
}
const token = Random.secret();
const tokenRecord = {
token,
// TODO: This should probably be renamed to "email" to match reset token record.
address: email,
when: new Date()
};
if (extraTokenData) {
Object.assign(tokenRecord, extraTokenData);
}
Meteor.users.update({_id: user._id}, {$push: {
'services.email.verificationTokens': tokenRecord
}});
// before passing to template, update user object with new token
Meteor._ensure(user, 'services', 'email');
if (!user.services.email.verificationTokens) {
user.services.email.verificationTokens = [];
}
user.services.email.verificationTokens.push(tokenRecord);
return {email, user, token};
};
// send the user an email with a link that when opened allows the user
// to set a new password, without the old password.
/**
* @summary Send an email with a link the user can use to reset their password.
* @locus Server
* @param {String} userId The id of the user to send email to.
* @param {String} [email] Optional. Which address of the user's to send the email to. This address must be in the user's `emails` list. Defaults to the first email in the list.
* @param {Object} [extraTokenData] Optional additional data to be added into the token record.
* @param {Object} [extraParams] Optional additional params to be added to the reset url.
* @returns {Object} Object with {email, user, token, url, options} values.
* @importFromPackage accounts-base
*/
Accounts.sendResetPasswordEmail = (userId, email, extraTokenData, extraParams) => {
const {email: realEmail, user, token} =
Accounts.generateResetToken(userId, email, 'resetPassword', extraTokenData);
const url = Accounts.urls.resetPassword(token, extraParams);
const options = Accounts.generateOptionsForEmail(realEmail, user, url, 'resetPassword');
Email.send(options);
if (Meteor.isDevelopment) {
console.log(`\nReset password URL: ${url}`);
}
return {email: realEmail, user, token, url, options};
};
// send the user an email informing them that their account was created, with
// a link that when opened both marks their email as verified and forces them
// to choose their password. The email must be one of the addresses in the
// user's emails field, or undefined to pick the first email automatically.
//
// This is not called automatically. It must be called manually if you
// want to use enrollment emails.
/**
* @summary Send an email with a link the user can use to set their initial password.
* @locus Server
* @param {String} userId The id of the user to send email to.
* @param {String} [email] Optional. Which address of the user's to send the email to. This address must be in the user's `emails` list. Defaults to the first email in the list.
* @param {Object} [extraTokenData] Optional additional data to be added into the token record.
* @param {Object} [extraParams] Optional additional params to be added to the enrollment url.
* @returns {Object} Object with {email, user, token, url, options} values.
* @importFromPackage accounts-base
*/
Accounts.sendEnrollmentEmail = (userId, email, extraTokenData, extraParams) => {
const {email: realEmail, user, token} =
Accounts.generateResetToken(userId, email, 'enrollAccount', extraTokenData);
const url = Accounts.urls.enrollAccount(token, extraParams);
const options = Accounts.generateOptionsForEmail(realEmail, user, url, 'enrollAccount');
Email.send(options);
if (Meteor.isDevelopment) {
console.log(`\nEnrollment email URL: ${url}`);
}
return {email: realEmail, user, token, url, options};
};
// Take token from sendResetPasswordEmail or sendEnrollmentEmail, change
// the users password, and log them in.
Meteor.methods({resetPassword: async function (...args) {
const token = args[0];
const newPassword = args[1];
return await Accounts._loginMethod(
this,
"resetPassword",
args,
"password",
async () => {
check(token, String);
check(newPassword, passwordValidator);
let user = Meteor.users.findOne(
{"services.password.reset.token": token},
{fields: {
services: 1,
emails: 1,
}}
);
let isEnroll = false;
// if token is in services.password.reset db field implies
// this method is was not called from enroll account workflow
// else this method is called from enroll account workflow
if(!user) {
user = Meteor.users.findOne(
{"services.password.enroll.token": token},
{fields: {
services: 1,
emails: 1,
}}
);
isEnroll = true;
}
if (!user) {
throw new Meteor.Error(403, "Token expired");
}
let tokenRecord = {};
if(isEnroll) {
tokenRecord = user.services.password.enroll;
} else {
tokenRecord = user.services.password.reset;
}
const { when, email } = tokenRecord;
let tokenLifetimeMs = Accounts._getPasswordResetTokenLifetimeMs();
if (isEnroll) {
tokenLifetimeMs = Accounts._getPasswordEnrollTokenLifetimeMs();
}
const currentTimeMs = Date.now();
if ((currentTimeMs - when) > tokenLifetimeMs)
throw new Meteor.Error(403, "Token expired");
if (!(pluckAddresses(user.emails).includes(email)))
return {
userId: user._id,
error: new Meteor.Error(403, "Token has invalid email address")
};
const hashed = await hashPassword(newPassword);
// NOTE: We're about to invalidate tokens on the user, who we might be
// logged in as. Make sure to avoid logging ourselves out if this
// happens. But also make sure not to leave the connection in a state
// of having a bad token set if things fail.
const oldToken = Accounts._getLoginToken(this.connection.id);
Accounts._setLoginToken(user._id, this.connection, null);
const resetToOldToken = () =>
Accounts._setLoginToken(user._id, this.connection, oldToken);
try {
// Update the user record by:
// - Changing the password to the new one
// - Forgetting about the reset token or enroll token that was just used
// - Verifying their email, since they got the password reset via email.
let affectedRecords = {};
// if reason is enroll then check services.password.enroll.token field for affected records
if(isEnroll) {
affectedRecords = Meteor.users.update(
{
_id: user._id,
'emails.address': email,
'services.password.enroll.token': token
},
{$set: {'services.password.bcrypt': hashed,
'emails.$.verified': true},
$unset: {'services.password.enroll': 1 }});
} else {
affectedRecords = Meteor.users.update(
{
_id: user._id,
'emails.address': email,
'services.password.reset.token': token
},
{$set: {'services.password.bcrypt': hashed,
'emails.$.verified': true},
$unset: {'services.password.reset': 1 }});
}
if (affectedRecords !== 1)
return {
userId: user._id,
error: new Meteor.Error(403, "Invalid email")
};
} catch (err) {
resetToOldToken();
throw err;
}
// Replace all valid login tokens with new ones (changing
// password should invalidate existing sessions).
Accounts._clearAllLoginTokens(user._id);
if (Accounts._check2faEnabled?.(user)) {
return {
userId: user._id,
error: Accounts._handleError(
'Changed password, but user not logged in because 2FA is enabled',
false,
'2fa-enabled'
),
};
}
return {userId: user._id};
}
);
}});
///
/// EMAIL VERIFICATION
///
// send the user an email with a link that when opened marks that
// address as verified
/**
* @summary Send an email with a link the user can use verify their email address.
* @locus Server
* @param {String} userId The id of the user to send email to.
* @param {String} [email] Optional. Which address of the user's to send the email to. This address must be in the user's `emails` list. Defaults to the first unverified email in the list.
* @param {Object} [extraTokenData] Optional additional data to be added into the token record.
* @param {Object} [extraParams] Optional additional params to be added to the verification url.
*
* @returns {Object} Object with {email, user, token, url, options} values.
* @importFromPackage accounts-base
*/
Accounts.sendVerificationEmail = (userId, email, extraTokenData, extraParams) => {
// XXX Also generate a link using which someone can delete this
// account if they own said address but weren't those who created
// this account.
const {email: realEmail, user, token} =
Accounts.generateVerificationToken(userId, email, extraTokenData);
const url = Accounts.urls.verifyEmail(token, extraParams);
const options = Accounts.generateOptionsForEmail(realEmail, user, url, 'verifyEmail');
Email.send(options);
if (Meteor.isDevelopment) {
console.log(`\nVerification email URL: ${url}`);
}
return {email: realEmail, user, token, url, options};
};
// Take token from sendVerificationEmail, mark the email as verified,
// and log them in.
Meteor.methods({verifyEmail: async function (...args) {
const token = args[0];
return await Accounts._loginMethod(
this,
"verifyEmail",
args,
"password",
() => {
check(token, String);
const user = Meteor.users.findOne(
{'services.email.verificationTokens.token': token},
{fields: {
services: 1,
emails: 1,
}}
);
if (!user)
throw new Meteor.Error(403, "Verify email link expired");
const tokenRecord = user.services.email.verificationTokens.find(
t => t.token == token
);
if (!tokenRecord)
return {
userId: user._id,
error: new Meteor.Error(403, "Verify email link expired")
};
const emailsRecord = user.emails.find(
e => e.address == tokenRecord.address
);
if (!emailsRecord)
return {
userId: user._id,
error: new Meteor.Error(403, "Verify email link is for unknown address")
};
// By including the address in the query, we can use 'emails.$' in the
// modifier to get a reference to the specific object in the emails
// array. See
// http://www.mongodb.org/display/DOCS/Updating/#Updating-The%24positionaloperator)
// http://www.mongodb.org/display/DOCS/Updating#Updating-%24pull
Meteor.users.update(
{_id: user._id,
'emails.address': tokenRecord.address},
{$set: {'emails.$.verified': true},
$pull: {'services.email.verificationTokens': {address: tokenRecord.address}}});
if (Accounts._check2faEnabled?.(user)) {
return {
userId: user._id,
error: Accounts._handleError(
'Email verified, but user not logged in because 2FA is enabled',
false,
'2fa-enabled'
),
};
}
return {userId: user._id};
}
);
}});
/**
* @summary Add an email address for a user. Use this instead of directly
* updating the database. The operation will fail if there is a different user
* with an email only differing in case. If the specified user has an existing
* email only differing in case however, we replace it.
* @locus Server
* @param {String} userId The ID of the user to update.
* @param {String} newEmail A new email address for the user.
* @param {Boolean} [verified] Optional - whether the new email address should
* be marked as verified. Defaults to false.
* @importFromPackage accounts-base
*/
Accounts.addEmail = (userId, newEmail, verified) => {
check(userId, NonEmptyString);
check(newEmail, NonEmptyString);
check(verified, Match.Optional(Boolean));
if (verified === void 0) {
verified = false;
}
const user = getUserById(userId, {fields: {emails: 1}});
if (!user)
throw new Meteor.Error(403, "User not found");
// Allow users to change their own email to a version with a different case
// We don't have to call checkForCaseInsensitiveDuplicates to do a case
// insensitive check across all emails in the database here because: (1) if
// there is no case-insensitive duplicate between this user and other users,
// then we are OK and (2) if this would create a conflict with other users
// then there would already be a case-insensitive duplicate and we can't fix
// that in this code anyway.
const caseInsensitiveRegExp =
new RegExp(`^${Meteor._escapeRegExp(newEmail)}$`, 'i');
const didUpdateOwnEmail = (user.emails || []).reduce(
(prev, email) => {
if (caseInsensitiveRegExp.test(email.address)) {
Meteor.users.update({
_id: user._id,
'emails.address': email.address
}, {$set: {
'emails.$.address': newEmail,
'emails.$.verified': verified
}});
return true;
} else {
return prev;
}
},
false
);
// In the other updates below, we have to do another call to
// checkForCaseInsensitiveDuplicates to make sure that no conflicting values
// were added to the database in the meantime. We don't have to do this for
// the case where the user is updating their email address to one that is the
// same as before, but only different because of capitalization. Read the
// big comment above to understand why.
if (didUpdateOwnEmail) {
return;
}
// Perform a case insensitive check for duplicates before update
Accounts._checkForCaseInsensitiveDuplicates('emails.address',
'Email', newEmail, user._id);
Meteor.users.update({
_id: user._id
}, {
$addToSet: {
emails: {
address: newEmail,
verified: verified
}
}
});
// Perform another check after update, in case a matching user has been
// inserted in the meantime
try {
Accounts._checkForCaseInsensitiveDuplicates('emails.address',
'Email', newEmail, user._id);
} catch (ex) {
// Undo update if the check fails
Meteor.users.update({_id: user._id},
{$pull: {emails: {address: newEmail}}});
throw ex;
}
}
/**
* @summary Remove an email address for a user. Use this instead of updating
* the database directly.
* @locus Server
* @param {String} userId The ID of the user to update.
* @param {String} email The email address to remove.
* @importFromPackage accounts-base
*/
Accounts.removeEmail = (userId, email) => {
check(userId, NonEmptyString);
check(email, NonEmptyString);
const user = getUserById(userId, {fields: {_id: 1}});
if (!user)
throw new Meteor.Error(403, "User not found");
Meteor.users.update({_id: user._id},
{$pull: {emails: {address: email}}});
}
///
/// CREATING USERS
///
// Shared createUser function called from the createUser method, both
// if originates in client or server code. Calls user provided hooks,
// does the actual user insertion.
//
// returns the user id
const createUser = async options => {
// Unknown keys allowed, because a onCreateUserHook can take arbitrary
// options.
check(options, Match.ObjectIncluding({
username: Match.Optional(String),
email: Match.Optional(String),
password: Match.Optional(passwordValidator)
}));
const { username, email, password } = options;
if (!username && !email)
throw new Meteor.Error(400, "Need to set a username or email");
const user = {services: {}};
if (password) {
const hashed = await hashPassword(password);
user.services.password = { bcrypt: hashed };
}
return Accounts._createUserCheckingDuplicates({ user, email, username, options });
};
// method for create user. Requests come from the client.
Meteor.methods({createUser: async function (...args) {
const options = args[0];
return await Accounts._loginMethod(
this,
"createUser",
args,
"password",
async () => {
// createUser() above does more checking.
check(options, Object);
if (Accounts._options.forbidClientAccountCreation)
return {
error: new Meteor.Error(403, "Signups forbidden")
};
const userId = await Accounts.createUserVerifyingEmail(options);
// client gets logged in as the new user afterwards.
return {userId: userId};
}
);
}});
/**
* @summary Creates an user and sends an email if `options.email` is informed.
* Then if the `sendVerificationEmail` option from the `Accounts` package is
* enabled, you'll send a verification email if `options.password` is informed,
* otherwise you'll send an enrollment email.
* @locus Server
* @param {Object} options The options object to be passed down when creating
* the user
* @param {String} options.username A unique name for this user.
* @param {String} options.email The user's email address.
* @param {String} options.password The user's password. This is __not__ sent in plain text over the wire.
* @param {Object} options.profile The user's profile, typically including the `name` field.
* @importFromPackage accounts-base
* */
Accounts.createUserVerifyingEmail = async (options) => {
options = { ...options };
// Create user. result contains id and token.
const userId = await createUser(options);
// safety belt. createUser is supposed to throw on error. send 500 error
// instead of sending a verification email with empty userid.
if (! userId)
throw new Error("createUser failed to insert new user");
// If `Accounts._options.sendVerificationEmail` is set, register
// a token to verify the user's primary email, and send it to
// that address.
if (options.email && Accounts._options.sendVerificationEmail) {
if (options.password) {
Accounts.sendVerificationEmail(userId, options.email);
} else {
Accounts.sendEnrollmentEmail(userId, options.email);
}
}
return userId;
};
// Create user directly on the server.
//
// Unlike the client version, this does not log you in as this user
// after creation.
//
// returns Promise<userId> or throws an error if it can't create
//
// XXX add another argument ("server options") that gets sent to onCreateUser,
// which is always empty when called from the createUser method? eg, "admin:
// true", which we want to prevent the client from setting, but which a custom
// method calling Accounts.createUser could set?
//
Accounts.createUserAsync = async (options, callback) => {
options = { ...options };
// XXX allow an optional callback?
if (callback) {
throw new Error("Accounts.createUser with callback not supported on the server yet.");
}
return createUser(options);
};
// Create user directly on the server.
//
// Unlike the client version, this does not log you in as this user
// after creation.
//
// returns userId or throws an error if it can't create
//
// XXX add another argument ("server options") that gets sent to onCreateUser,
// which is always empty when called from the createUser method? eg, "admin:
// true", which we want to prevent the client from setting, but which a custom
// method calling Accounts.createUser could set?
//
Accounts.createUser = (options, callback) => {
return Promise.await(Accounts.createUserAsync(options, callback));
};
///
/// PASSWORD-SPECIFIC INDEXES ON USERS
///
Meteor.users.createIndexAsync('services.email.verificationTokens.token',
{ unique: true, sparse: true });
Meteor.users.createIndexAsync('services.password.reset.token',
{ unique: true, sparse: true });
Meteor.users.createIndexAsync('services.password.enroll.token',
{ unique: true, sparse: true });