Merge pull request #13607 from meteor/argon2-support

Argon2 support
This commit is contained in:
Nacho Codoñer
2025-02-07 14:37:39 +01:00
committed by GitHub
9 changed files with 778 additions and 312 deletions

View File

@@ -82,6 +82,11 @@ export namespace Accounts {
passwordEnrollTokenExpirationInDays?: number | undefined;
ambiguousErrorMessages?: boolean | undefined;
bcryptRounds?: number | undefined;
argon2Enabled?: string | false;
argon2Type?: string | undefined;
argon2TimeCost: number | undefined;
argon2MemoryCost: number | undefined;
argon2Parallelism: number | undefined;
defaultFieldSelector?: { [key: string]: 0 | 1 } | undefined;
collection?: string | undefined;
loginTokenExpirationHours?: number | undefined;
@@ -353,10 +358,10 @@ export namespace Accounts {
/**
*
* Check whether the provided password matches the bcrypt'ed password in
* Check whether the provided password matches the encrypted 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
* it will be run through SHA256 before bcrypt or argon2) or an object with
* properties `digest` and `algorithm` (in which case we bcrypt/argon2
* `password.digest`).
*/
function _checkPasswordAsync(

View File

@@ -14,6 +14,11 @@ const VALID_CONFIG_KEYS = [
'passwordEnrollTokenExpiration',
'ambiguousErrorMessages',
'bcryptRounds',
'argon2Enabled',
'argon2Type',
'argon2TimeCost',
'argon2MemoryCost',
'argon2Parallelism',
'defaultFieldSelector',
'collection',
'loginTokenExpirationHours',
@@ -194,41 +199,6 @@ export class AccountsCommon {
? this.users.findOneAsync(userId, this._addDefaultFieldSelector(options))
: null;
}
// Set up config for the accounts system. Call this on both the client
// and the server.
//
// Note that this method gets overridden on AccountsServer.prototype, but
// the overriding method calls the overridden method.
//
// XXX we should add some enforcement that this is called on both the
// client and the server. Otherwise, a user can
// 'forbidClientAccountCreation' only on the client and while it looks
// like their app is secure, the server will still accept createUser
// calls. https://github.com/meteor/meteor/issues/828
//
// @param options {Object} an object with fields:
// - sendVerificationEmail {Boolean}
// Send email address verification emails to new users created from
// client signups.
// - forbidClientAccountCreation {Boolean}
// Do not allow clients to create accounts directly.
// - restrictCreationByEmailDomain {Function or String}
// Require created users to have an email matching the function or
// having the string as domain.
// - loginExpirationInDays {Number}
// Number of days since login until a user is logged out (login token
// expires).
// - collection {String|Mongo.Collection}
// A collection name or a Mongo.Collection object to hold the users.
// - passwordResetTokenExpirationInDays {Number}
// Number of days since password reset token creation until the
// token can't be used any longer (password reset token expires).
// - ambiguousErrorMessages {Boolean}
// Return ambiguous error messages from login failures to prevent
// user enumeration.
// - bcryptRounds {Number}
// Allows override of number of bcrypt rounds (aka work factor) used
// to store passwords.
/**
* @summary Set global accounts options. You can also set these in `Meteor.settings.packages.accounts` without the need to call this function.
@@ -246,6 +216,11 @@ export class AccountsCommon {
* @param {Number} options.passwordEnrollTokenExpiration The number of milliseconds from when a link to set initial password is sent until token expires and user can't set password with the link anymore. If `passwordEnrollTokenExpirationInDays` is set, it takes precedent.
* @param {Boolean} options.ambiguousErrorMessages Return ambiguous error messages from login failures to prevent user enumeration. Defaults to `true`.
* @param {Number} options.bcryptRounds Allows override of number of bcrypt rounds (aka work factor) used to store passwords. The default is 10.
* @param {Boolean} options.argon2Enabled Enable argon2 algorithm usage in replacement for bcrypt. The default is `false`.
* @param {'argon2id' | 'argon2i' | 'argon2d'} options.argon2Type Allows override of the argon2 algorithm type. The default is `argon2id`.
* @param {Number} options.argon2TimeCost Allows override of number of argon2 iterations (aka time cost) used to store passwords. The default is 3.
* @param {Number} options.argon2MemoryCost Allows override of the amount of memory (in KiB) used by the argon2 algorithm. The default is 65536 (64MB).
* @param {Number} options.argon2Parallelism Allows override of the number of threads used by the argon2 algorithm. The default is 4.
* @param {MongoFieldSpecifier} options.defaultFieldSelector To exclude by default large custom fields from `Meteor.user()` and `Meteor.findUserBy...()` functions when called without a field selector, and all `onLogin`, `onLoginFailure` and `onLogout` callbacks. Example: `Accounts.config({ defaultFieldSelector: { myBigArray: 0 }})`. Beware when using this. If, for instance, you do not include `email` when excluding the fields, you can have problems with functions like `forgotPassword` that will break because they won't have the required data available. It's recommend that you always keep the fields `_id`, `username`, and `email`.
* @param {String|Mongo.Collection} options.collection A collection name or a Mongo.Collection object to hold the users.
* @param {Number} options.loginTokenExpirationHours When using the package `accounts-2fa`, use this to set the amount of time a token sent is valid. As it's just a number, you can use, for example, 0.5 to make the token valid for just half hour. The default is 1 hour.

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: "A user account system",
version: "3.0.4",
version: "3.1.0-alpha.0",
});
Package.onUse((api) => {

View File

@@ -5,18 +5,19 @@ Package.describe({
// 2.2.x in the future. The version was also bumped to 2.0.0 temporarily
// during the Meteor 1.5.1 release process, so versions 2.0.0-beta.2
// through -beta.5 and -rc.0 have already been published.
version: "3.0.3",
version: "4.0.0-alpha.0",
});
Npm.depends({
bcrypt: "5.0.1",
argon2: "0.41.1",
});
Package.onUse((api) => {
api.use(["accounts-base", "sha", "ejson", "ddp"], ["client", "server"]);
api.use(["accounts-base@3.1.0-alpha.0", "sha", "ejson", "ddp"], ["client", "server"]);
// Export Accounts (etc) to packages using this one.
api.imply("accounts-base", ["client", "server"]);
api.imply("accounts-base@3.1.0-alpha.0", ["client", "server"]);
api.use("email", "server");
api.use("random", "server");
@@ -40,10 +41,11 @@ Package.onTest((api) => {
"email",
"check",
"ddp",
"ecmascript",
"ecmascript"
]);
api.addFiles("password_tests_setup.js", "server");
api.addFiles("password_tests.js", ["client", "server"]);
api.addFiles("email_tests_setup.js", "server");
api.addFiles("email_tests.js", "client");
api.addFiles("password_argon_tests.js", ["client", "server"]);
});

View File

@@ -0,0 +1,221 @@
if (Meteor.isServer) {
Tinytest.addAsync("passwords Argon - migration from bcrypt encryption to argon2", async (test) => {
Accounts._options.argon2Enabled = false;
const username = Random.id();
const email = `${username}@bcrypt.com`;
const password = "password";
const userId = await Accounts.createUser(
{
username: username,
email: email,
password: password
}
);
Accounts._options.argon2Enabled = true;
let user = await Meteor.users.findOneAsync(userId);
const isValid = await Accounts._checkPasswordAsync(user, password);
test.equal(isValid.userId, userId, "checkPassword with bcrypt - User ID should be returned");
test.equal(typeof isValid.error, "undefined", "checkPassword with bcrypt - No error should be returned");
// wait for the migration to happen
await waitUntil(
async () => {
user = await Meteor.users.findOneAsync(userId);
return (
typeof user.services.password.bcrypt === "undefined" &&
typeof user.services.password.argon2 === "string"
);
},
{ description: "bcrypt should be unset and argon2 should be set" }
);
// password is still valid using argon2
const isValidArgon = await Accounts._checkPasswordAsync(user, password);
test.equal(isValidArgon.userId, userId, "checkPassword with argon2 - User ID should be returned");
test.equal(typeof isValidArgon.error, "undefined", "checkPassword with argon2 - No error should be returned");
// cleanup
Accounts._options.argon2Enabled = false;
await Meteor.users.removeAsync(userId);
});
Tinytest.addAsync("passwords Argon - setPassword", async (test) => {
Accounts._options.argon2Enabled = true;
const username = Random.id();
const email = `${username}-intercept@example.com`;
const userId = await Accounts.createUser({ username: username, email: email });
let user = await Meteor.users.findOneAsync(userId);
// no services yet.
test.equal(user.services.password, undefined);
// set a new password.
await Accounts.setPasswordAsync(userId, "new password");
user = await Meteor.users.findOneAsync(userId);
const oldSaltedHash = user.services.password.argon2;
test.isTrue(oldSaltedHash);
// Send a reset password email (setting a reset token) and insert a login
// token.
await Accounts.sendResetPasswordEmail(userId, email);
await Accounts._insertLoginToken(userId, Accounts._generateStampedLoginToken());
const user2 = await Meteor.users.findOneAsync(userId);
test.isTrue(user2.services.password.reset);
test.isTrue(user2.services.resume.loginTokens);
// reset with the same password, see we get a different salted hash
await Accounts.setPasswordAsync(userId, "new password", { logout: false });
user = await Meteor.users.findOneAsync(userId);
const newSaltedHash = user.services.password.argon2;
test.isTrue(newSaltedHash);
test.notEqual(oldSaltedHash, newSaltedHash);
// No more reset token.
const user3 = await Meteor.users.findOneAsync(userId);
test.isFalse(user3.services.password.reset);
// But loginTokens are still here since we did logout: false.
test.isTrue(user3.services.resume.loginTokens);
// reset again, see that the login tokens are gone.
await Accounts.setPasswordAsync(userId, "new password");
user = await Meteor.users.findOneAsync(userId);
const newerSaltedHash = user.services.password.argon2;
test.isTrue(newerSaltedHash);
test.notEqual(oldSaltedHash, newerSaltedHash);
test.notEqual(newSaltedHash, newerSaltedHash);
// No more tokens.
const user4 = await Meteor.users.findOneAsync(userId);
test.isFalse(user4.services.password.reset);
test.isFalse(user4.services.resume.loginTokens);
// cleanup
Accounts._options.argon2Enabled = false;
await Meteor.users.removeAsync(userId);
});
Tinytest.addAsync("passwords Argon - migration from argon2 encryption to bcrypt", async (test) => {
Accounts._options.argon2Enabled = true;
const username = Random.id();
const email = `${username}@bcrypt.com`;
const password = "password";
const userId = await Accounts.createUser(
{
username: username,
email: email,
password: password
}
);
Accounts._options.argon2Enabled = false;
let user = await Meteor.users.findOneAsync(userId);
const isValidArgon = await Accounts._checkPasswordAsync(user, password);
test.equal(isValidArgon.userId, userId, "checkPassword with argon2 - User ID should be returned");
test.equal(typeof isValidArgon.error, "undefined", "checkPassword with argon2 - No error should be returned");
// wait for the migration to happen
await waitUntil(
async () => {
user = await Meteor.users.findOneAsync(userId);
return (
typeof user.services.password.bcrypt === "string" &&
typeof user.services.password.argon2 === "undefined"
);
},
{ description: "bcrypt should be string and argon2 should be undefined" }
);
// password is still valid using bcrypt
const isValidBcrypt = await Accounts._checkPasswordAsync(user, password);
test.equal(isValidBcrypt.userId, userId, "checkPassword with argon2 - User ID should be returned");
test.equal(typeof isValidBcrypt.error, "undefined", "checkPassword with argon2 - No error should be returned");
// cleanup
await Meteor.users.removeAsync(userId);
});
const getUserHashArgon2Params = function (user) {
const hash = user?.services?.password?.argon2;
return Accounts._getArgon2Params(hash);
}
const hashPasswordWithSha = function (password) {
return {
digest: SHA256(password),
algorithm: "sha-256"
};
}
testAsyncMulti("passwords Argon - allow custom argon2 Params and ensure migration if changed", [
async function(test) {
Accounts._options.argon2Enabled = true;
// Verify that a argon2 hash generated for a new account uses the
// default params.
let username = Random.id();
this.password = hashPasswordWithSha("abc123");
this.userId1 = await Accounts.createUserAsync({ username, password: this.password });
this.user1 = await Meteor.users.findOneAsync(this.userId1);
let argon2Params = getUserHashArgon2Params(this.user1);
test.equal(argon2Params.type, Accounts._argon2Type());
test.equal(argon2Params.memoryCost, Accounts._argon2MemoryCost());
test.equal(argon2Params.timeCost, Accounts._argon2TimeCost());
test.equal(argon2Params.parallelism, Accounts._argon2Parallelism());
// When a custom number of argon2 TimeCost is set via Accounts.config,
// and an account was already created using the default number of TimeCost,
// make sure that a new hash is created (and stored) using the new number
// of TimeCost, the next time the password is checked.
this.customType = "argon2d"; // argon2.argon2d = 2
this.customTimeCost = 4;
this.customMemoryCost = 32768;
this.customParallelism = 1;
Accounts._options.argon2Type = this.customType;
Accounts._options.argon2TimeCost = this.customTimeCost;
Accounts._options.argon2MemoryCost = this.customMemoryCost;
Accounts._options.argon2Parallelism = this.customParallelism;
await Accounts._checkPasswordAsync(this.user1, this.password);
},
async function(test) {
const defaultType = Accounts._argon2Type();
const defaultTimeCost = Accounts._argon2TimeCost();
const defaultMemoryCost = Accounts._argon2MemoryCost();
const defaultParallelism = Accounts._argon2Parallelism();
let params;
let username;
let resolve;
const promise = new Promise(res => resolve = res);
Meteor.setTimeout(async () => {
this.user1 = await Meteor.users.findOneAsync(this.userId1);
params = getUserHashArgon2Params(this.user1);
test.equal(params.type, 2);
test.equal(params.timeCost, this.customTimeCost);
test.equal(params.memoryCost, this.customMemoryCost);
test.equal(params.parallelism, this.customParallelism);
// When a custom number of argon2 TimeCost is set, make sure it's
// used for new argon2 password hashes.
username = Random.id();
const userId2 = await Accounts.createUser({ username, password: this.password });
const user2 = await Meteor.users.findOneAsync(userId2);
params = getUserHashArgon2Params(user2);
test.equal(params.type, 2);
test.equal(params.timeCost, this.customTimeCost);
test.equal(params.memoryCost, this.customMemoryCost);
test.equal(params.parallelism, this.customParallelism);
// Cleanup
Accounts._options.argon2Enabled = false;
Accounts._options.argon2Type = defaultType;
Accounts._options.argon2TimeCost = defaultTimeCost;
Accounts._options.argon2MemoryCost = defaultMemoryCost;
Accounts._options.argon2Parallelism = defaultParallelism;
await Meteor.users.removeAsync(this.userId1);
await Meteor.users.removeAsync(userId2);
resolve();
}, 1000);
return promise;
}
]);
}

View File

@@ -1,4 +1,5 @@
import { hash as bcryptHash, compare as bcryptCompare } from 'bcrypt';
import argon2 from "argon2";
import { hash as bcryptHash, compare as bcryptCompare } from "bcrypt";
import { Accounts } from "meteor/accounts-base";
// Utility for grabbing user
@@ -6,8 +7,9 @@ const getUserById =
async (id, options) =>
await Meteor.users.findOneAsync(id, Accounts._addDefaultFieldSelector(options));
// User records have a 'services.password.bcrypt' field on them to hold
// their hashed passwords.
// User records have two fields that are used for password-based login:
// - 'services.password.bcrypt', which stores the bcrypt password, which will be deprecated
// - 'services.password.argon2', which stores the argon2 password
//
// 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
@@ -17,90 +19,268 @@ const getUserById =
// 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
// hashes it with SHA256 before passing it into bcrypt / argon2. When the server
// receives a password as an object, it asserts that the algorithm is
// "sha-256" and then passes the digest to bcrypt.
// "sha-256" and then passes the digest to bcrypt / argon2.
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".
//
Accounts._argon2Enabled = () => Accounts._options.argon2Enabled || false;
const ARGON2_TYPES = {
argon2i: argon2.argon2i,
argon2d: argon2.argon2d,
argon2id: argon2.argon2id
};
Accounts._argon2Type = () => ARGON2_TYPES[Accounts._options.argon2Type] || argon2.argon2id;
Accounts._argon2TimeCost = () => Accounts._options.argon2TimeCost || 3;
Accounts._argon2MemoryCost = () => Accounts._options.argon2MemoryCost || 65536;
Accounts._argon2Parallelism = () => Accounts._options.argon2Parallelism || 4;
/**
* Extracts the string to be encrypted using bcrypt or Argon2 from the given `password`.
*
* @param {string|Object} password - The password provided by the client. It can be:
* - A plaintext string password.
* - An object with the following properties:
* @property {string} digest - The hashed password.
* @property {string} algorithm - The hashing algorithm used. Must be "sha-256".
*
* @returns {string} - The resulting password string to encrypt.
*
* @throws {Error} - If the `algorithm` in the password object is not "sha-256".
*/
const getPasswordString = password => {
if (typeof password === "string") {
password = SHA256(password);
} else { // 'password' is an object
}
else { // 'password' is an object
if (password.algorithm !== "sha-256") {
throw new Error("Invalid password hash algorithm. " +
"Only 'sha-256' is allowed.");
"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 => {
/**
* Encrypt the given `password` using either bcrypt or Argon2.
* @param password can be a string (in which case it will be run through SHA256 before encryption) or an object with properties `digest` and `algorithm` (in which case we bcrypt or Argon2 `password.digest`).
* @returns {Promise<string>} The encrypted password.
*/
const hashPassword = async (password) => {
password = getPasswordString(password);
return await bcryptHash(password, Accounts._bcryptRounds());
if (Accounts._argon2Enabled() === true) {
return await argon2.hash(password, {
type: Accounts._argon2Type(),
timeCost: Accounts._argon2TimeCost(),
memoryCost: Accounts._argon2MemoryCost(),
parallelism: Accounts._argon2Parallelism()
});
}
else {
return await bcryptHash(password, Accounts._bcryptRounds());
}
};
// Extract the number of rounds used in the specified bcrypt hash.
const getRoundsFromBcryptHash = hash => {
const getRoundsFromBcryptHash = (hash) => {
let rounds;
if (hash) {
const hashSegments = hash.split('$');
const hashSegments = hash.split("$");
if (hashSegments.length > 2) {
rounds = parseInt(hashSegments[2], 10);
}
}
return rounds;
};
Accounts._getRoundsFromBcryptHash = getRoundsFromBcryptHash;
// 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};
//
/**
* Extract readable parameters from an Argon2 hash string.
* @param {string} hash - The Argon2 hash string.
* @returns {object} An object containing the parsed parameters.
* @throws {Error} If the hash format is invalid.
*/
function getArgon2Params(hash) {
const regex = /^\$(argon2(?:i|d|id))\$v=\d+\$m=(\d+),t=(\d+),p=(\d+)/;
const match = hash.match(regex);
if (!match) {
throw new Error("Invalid Argon2 hash format.");
}
const [, type, memoryCost, timeCost, parallelism] = match;
return {
type: ARGON2_TYPES[type],
timeCost: parseInt(timeCost, 10),
memoryCost: parseInt(memoryCost, 10),
parallelism: parseInt(parallelism, 10)
};
}
Accounts._getArgon2Params = getArgon2Params;
const getUserPasswordHash = user => {
return user.services?.password?.argon2 || user.services?.password?.bcrypt;
};
Accounts._checkPasswordUserFields = { _id: 1, services: 1 };
const isBcrypt = (hash) => {
// bcrypt hashes start with $2a$ or $2b$
return hash.startsWith("$2");
};
const isArgon = (hash) => {
// argon2 hashes start with $argon2i$, $argon2d$ or $argon2id$
return hash.startsWith("$argon2");
}
const updateUserPasswordDefered = (user, formattedPassword) => {
Meteor.defer(async () => {
await updateUserPassword(user, formattedPassword);
});
};
/**
* Hashes the provided password and returns an object that can be used to update the user's password.
* @param formattedPassword
* @returns {Promise<{$set: {"services.password.bcrypt": string}}|{$unset: {"services.password.bcrypt": number}, $set: {"services.password.argon2": string}}>}
*/
const getUpdatorForUserPassword = async (formattedPassword) => {
const encryptedPassword = await hashPassword(formattedPassword);
if (Accounts._argon2Enabled() === false) {
return {
$set: {
"services.password.bcrypt": encryptedPassword
},
$unset: {
"services.password.argon2": 1
}
};
}
else if (Accounts._argon2Enabled() === true) {
return {
$set: {
"services.password.argon2": encryptedPassword
},
$unset: {
"services.password.bcrypt": 1
}
};
}
};
const updateUserPassword = async (user, formattedPassword) => {
const updator = await getUpdatorForUserPassword(formattedPassword);
await Meteor.users.updateAsync({ _id: user._id }, updator);
};
/**
* Checks whether the provided password matches the hashed password stored in the user's database record.
*
* @param {Object} user - The user object containing at least:
* @property {string} _id - The user's unique identifier.
* @property {Object} services - The user's services data.
* @property {Object} services.password - The user's password object.
* @property {string} [services.password.argon2] - The Argon2 hashed password.
* @property {string} [services.password.bcrypt] - The bcrypt hashed password, deprecated
*
* @param {string|Object} password - The password provided by the client. It can be:
* - A plaintext string password.
* - An object with the following properties:
* @property {string} digest - The hashed password.
* @property {string} algorithm - The hashing algorithm used. Must be "sha-256".
*
* @returns {Promise<Object>} - A result object with the following properties:
* @property {string} userId - The user's unique identifier.
* @property {Object} [error] - An error object if the password does not match or an error occurs.
*
* @throws {Error} - If an unexpected error occurs during the process.
*/
const checkPasswordAsync = async (user, password) => {
const result = {
userId: user._id
};
const formattedPassword = getPasswordString(password);
const hash = user.services.password.bcrypt;
const hashRounds = getRoundsFromBcryptHash(hash);
const hash = getUserPasswordHash(user);
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 () => {
await Meteor.users.updateAsync({ _id: user._id }, {
$set: {
'services.password.bcrypt':
await bcryptHash(formattedPassword, Accounts._bcryptRounds())
const argon2Enabled = Accounts._argon2Enabled();
if (argon2Enabled === false) {
if (isArgon(hash)) {
// this is a rollback feature, enabling to switch back from argon2 to bcrypt if needed
// TODO : deprecate this
console.warn("User has an argon2 password and argon2 is not enabled, rolling back to bcrypt encryption");
const match = await argon2.verify(hash, formattedPassword);
if (!match) {
result.error = Accounts._handleError("Incorrect password", false);
}
else{
// The password checks out, but the user's stored password needs to be updated to argon2
updateUserPasswordDefered(user, { digest: formattedPassword, algorithm: "sha-256" });
}
}
else {
const hashRounds = getRoundsFromBcryptHash(hash);
const match = await bcryptCompare(formattedPassword, hash);
if (!match) {
result.error = Accounts._handleError("Incorrect password", false);
}
else if (hash) {
const paramsChanged = hashRounds !== Accounts._bcryptRounds();
// The password checks out, but the user's bcrypt hash needs to be updated
// to match current bcrypt settings
if (paramsChanged === true) {
updateUserPasswordDefered(user, { digest: formattedPassword, algorithm: "sha-256" });
}
});
});
}
}
}
else if (argon2Enabled === true) {
if (isBcrypt(hash)) {
// migration code from bcrypt to argon2
const match = await bcryptCompare(formattedPassword, hash);
if (!match) {
result.error = Accounts._handleError("Incorrect password", false);
}
else {
// The password checks out, but the user's stored password needs to be updated to argon2
updateUserPasswordDefered(user, { digest: formattedPassword, algorithm: "sha-256" });
}
}
else {
// argon2 password
const argon2Params = getArgon2Params(hash);
const match = await argon2.verify(hash, formattedPassword);
if (!match) {
result.error = Accounts._handleError("Incorrect password", false);
}
else if (hash) {
const paramsChanged = argon2Params.memoryCost !== Accounts._argon2MemoryCost() ||
argon2Params.timeCost !== Accounts._argon2TimeCost() ||
argon2Params.parallelism !== Accounts._argon2Parallelism() ||
argon2Params.type !== Accounts._argon2Type();
if (paramsChanged === true) {
// The password checks out, but the user's argon2 hash needs to be updated with the right params
updateUserPasswordDefered(user, { digest: formattedPassword, algorithm: "sha-256" });
}
}
}
}
return result;
};
Accounts._checkPasswordAsync = checkPasswordAsync;
Accounts._checkPasswordAsync = checkPasswordAsync;
///
/// LOGIN
@@ -185,9 +365,7 @@ Accounts.registerLoginHandler("password", async options => {
Accounts._handleError("User not found");
}
if (!user.services || !user.services.password ||
!user.services.password.bcrypt) {
if (!getUserPasswordHash(user)) {
Accounts._handleError("User has no password set");
}
@@ -267,51 +445,54 @@ Accounts.setUsername =
// `digest` and `algorithm` (representing the SHA256 of the password).
Meteor.methods(
{
changePassword: async function (oldPassword, newPassword) {
check(oldPassword, passwordValidator);
check(newPassword, passwordValidator);
changePassword: async function(oldPassword, newPassword) {
check(oldPassword, passwordValidator);
check(newPassword, passwordValidator);
if (!this.userId) {
throw new Meteor.Error(401, "Must be logged in");
}
if (!this.userId) {
throw new Meteor.Error(401, "Must be logged in");
}
const user = await getUserById(this.userId, {fields: {
services: 1,
...Accounts._checkPasswordUserFields,
}});
if (!user) {
Accounts._handleError("User not found");
}
const user = await 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");
}
if (!getUserPasswordHash(user)) {
Accounts._handleError("User has no password set");
}
const result = await checkPasswordAsync(user, oldPassword);
if (result.error) {
throw result.error;
}
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);
const updator = await getUpdatorForUserPassword(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);
await Meteor.users.updateAsync(
{ _id: this.userId },
{
$set: { 'services.password.bcrypt': hashed },
$pull: {
'services.resume.loginTokens': { hashedToken: { $ne: currentToken } }
},
$unset: { 'services.password.reset': 1 }
await Meteor.users.updateAsync(
{ _id: this.userId },
{
$set: updator.$set,
$pull: {
"services.resume.loginTokens": { hashedToken: { $ne: currentToken } }
},
$unset: { "services.password.reset": 1, ...updator.$unset }
}
);
return { passwordChanged: true };
}
);
return {passwordChanged: true};
}});
});
// Force change the users password.
@@ -320,37 +501,34 @@ Meteor.methods(
* @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 {String} newPlaintextPassword 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 };
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 = await getUserById(userId, { fields: { _id: 1 } });
if (!user) {
throw new Meteor.Error(403, "User not found");
}
const user = await 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)}
let updator = await getUpdatorForUserPassword(newPlaintextPassword);
updator.$unset = updator.$unset || {};
updator.$unset["services.password.reset"] = 1;
if (options.logout) {
updator.$unset["services.resume.loginTokens"] = 1;
}
await Meteor.users.updateAsync({ _id: user._id }, updator);
};
if (options.logout) {
update.$unset['services.resume.loginTokens'] = 1;
}
await Meteor.users.updateAsync({_id: user._id}, update);
};
///
/// RESETTING VIA EMAIL
///
@@ -430,25 +608,32 @@ Accounts.generateResetToken =
// 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') {
await Meteor.users.updateAsync({_id: user._id}, {
$set : {
'services.password.enroll': tokenRecord
if (reason === "enrollAccount") {
await Meteor.users.updateAsync(
{ _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 {
await Meteor.users.updateAsync({_id: user._id}, {
$set : {
'services.password.reset': tokenRecord
Meteor._ensure(user, "services", "password").enroll = tokenRecord;
}
else {
await Meteor.users.updateAsync(
{ _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;
Meteor._ensure(user, "services", "password").reset = tokenRecord;
}
return {email, user, token};
return { email, user, token };
};
/**
@@ -534,7 +719,7 @@ Accounts.sendResetPasswordEmail =
const options = await Accounts.generateOptionsForEmail(realEmail, user, url, 'resetPassword');
await Email.sendAsync(options);
if (Meteor.isDevelopment) {
if (Meteor.isDevelopment && !Meteor.isPackageTest) {
console.log(`\nReset password URL: ${ url }`);
}
return { email: realEmail, user, token, url, options };
@@ -570,7 +755,7 @@ Accounts.sendEnrollmentEmail =
await Accounts.generateOptionsForEmail(realEmail, user, url, 'enrollAccount');
await Email.sendAsync(options);
if (Meteor.isDevelopment) {
if (Meteor.isDevelopment && !Meteor.isPackageTest) {
console.log(`\nEnrollment email URL: ${ url }`);
}
return { email: realEmail, user, token, url, options };
@@ -642,8 +827,6 @@ Meteor.methods(
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
@@ -653,6 +836,8 @@ Meteor.methods(
const resetToOldToken = () =>
Accounts._setLoginToken(user._id, this.connection, oldToken);
const updator = await getUpdatorForUserPassword(newPassword);
try {
// Update the user record by:
// - Changing the password to the new one
@@ -664,29 +849,36 @@ Meteor.methods(
affectedRecords = await Meteor.users.updateAsync(
{
_id: user._id,
'emails.address': email,
'services.password.enroll.token': token
"emails.address": email,
"services.password.enroll.token": token
},
{
$set: {
'services.password.bcrypt': hashed,
'emails.$.verified': true
"emails.$.verified": true,
...updator.$set
},
$unset: { 'services.password.enroll': 1 }
$unset: {
"services.password.enroll": 1,
...updator.$unset
}
});
} else {
}
else {
affectedRecords = await Meteor.users.updateAsync(
{
_id: user._id,
'emails.address': email,
'services.password.reset.token': token
"emails.address": email,
"services.password.reset.token": token
},
{
$set: {
'services.password.bcrypt': hashed,
'emails.$.verified': true
"emails.$.verified": true,
...updator.$set
},
$unset: { 'services.password.reset': 1 }
$unset: {
"services.password.reset": 1,
...updator.$unset
}
});
}
if (affectedRecords !== 1)
@@ -704,15 +896,16 @@ Meteor.methods(
await 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 };
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 };
}
);
}
@@ -748,7 +941,7 @@ Accounts.sendVerificationEmail =
const url = Accounts.urls.verifyEmail(token, extraParams);
const options = await Accounts.generateOptionsForEmail(realEmail, user, url, 'verifyEmail');
await Email.sendAsync(options);
if (Meteor.isDevelopment) {
if (Meteor.isDevelopment && !Meteor.isPackageTest) {
console.log(`\nVerification email URL: ${ url }`);
}
return { email: realEmail, user, token, url, options };
@@ -990,7 +1183,13 @@ const createUser =
const user = { services: {} };
if (password) {
const hashed = await hashPassword(password);
user.services.password = { bcrypt: hashed };
const argon2Enabled = Accounts._argon2Enabled();
if (argon2Enabled === false) {
user.services.password = { bcrypt: hashed };
}
else {
user.services.password = { argon2: hashed };
}
}
return await Accounts._createUserCheckingDuplicates({ user, email, username, options });

View File

@@ -10,7 +10,7 @@ const makeTestConnAsync =
})
const simplePollAsync = (fn) =>
new Promise((resolve, reject) => simplePoll(fn,resolve,reject))
function hashPassword(password) {
function hashPasswordWithSha(password) {
return {
digest: SHA256(password),
algorithm: "sha-256"
@@ -486,7 +486,7 @@ if (Meteor.isClient) (() => {
function (test, expect) {
this.secondConn = DDP.connect(Meteor.absoluteUrl());
this.secondConn.call('login',
{ user: { username: this.username }, password: hashPassword(this.password) },
{ user: { username: this.username }, password: hashPasswordWithSha(this.password) },
expect((err, result) => {
test.isFalse(err);
this.secondConn.setUserId(result.id);
@@ -802,7 +802,7 @@ if (Meteor.isClient) (() => {
// Can update own profile using ID.
await Meteor.users.updateAsync(
this.userId, { $set: { 'profile.updated': 42 } },
);
);
test.equal(42, Meteor.user().profile.updated);
},
logoutStep
@@ -1230,7 +1230,7 @@ if (Meteor.isServer) (() => {
const username = Random.id();
const id = await Accounts.createUser({
username: username,
password: hashPassword('password')
password: hashPasswordWithSha('password')
});
const {
@@ -1245,7 +1245,7 @@ if (Meteor.isServer) (() => {
const result = await clientConn.callAsync('login', {
user: { username: username },
password: hashPassword('password')
password: hashPasswordWithSha('password')
});
test.isTrue(result);
@@ -1278,7 +1278,7 @@ if (Meteor.isServer) (() => {
const userId = await Accounts.createUser({
username: username,
email: email,
password: hashPassword("old-password")
password: hashPasswordWithSha("old-password")
});
const user = await Meteor.users.findOneAsync(userId);
@@ -1297,7 +1297,7 @@ if (Meteor.isServer) (() => {
await test.throwsAsync(
async () =>
await Meteor.callAsync("resetPassword", resetPasswordToken, hashPassword("new-password")),
await Meteor.callAsync("resetPassword", resetPasswordToken, hashPasswordWithSha("new-password")),
/Token has invalid email address/
);
await test.throwsAsync(
@@ -1306,7 +1306,7 @@ if (Meteor.isServer) (() => {
"login",
{
user: { username: username },
password: hashPassword("new-password")
password: hashPasswordWithSha("new-password")
}
),
/Something went wrong. Please check your credentials./);
@@ -1321,7 +1321,7 @@ if (Meteor.isServer) (() => {
const userId = await Accounts.createUser({
username: username,
email: email,
password: hashPassword("old-password")
password: hashPasswordWithSha("old-password")
});
const user = await Meteor.users.findOneAsync(userId);
@@ -1338,11 +1338,11 @@ if (Meteor.isServer) (() => {
test.isTrue(await clientConn.callAsync(
"resetPassword",
resetPasswordToken,
hashPassword("new-password")
hashPasswordWithSha("new-password")
));
test.isTrue(await clientConn.callAsync("login", {
user: { username },
password: hashPassword("new-password")
password: hashPasswordWithSha("new-password")
}));
});
@@ -1355,7 +1355,7 @@ if (Meteor.isServer) (() => {
const userId = await Accounts.createUser({
username: username,
email: email,
password: hashPassword("old-password")
password: hashPasswordWithSha("old-password")
});
const user = await Meteor.users.findOneAsync(userId);
@@ -1373,7 +1373,7 @@ if (Meteor.isServer) (() => {
await Meteor.users.updateAsync(userId, { $set: { "services.password.reset.when": new Date(Date.now() + -5 * 24 * 3600 * 1000) } });
try {
await Meteor.callAsync("resetPassword", resetPasswordToken, hashPassword("new-password"))
await Meteor.callAsync("resetPassword", resetPasswordToken, hashPasswordWithSha("new-password"))
} catch (e) {
test.throws(() => {
throw e;
@@ -1385,7 +1385,7 @@ if (Meteor.isServer) (() => {
"login",
{
user: { username: username },
password: hashPassword("new-password")
password: hashPasswordWithSha("new-password")
}
),
/Something went wrong. Please check your credentials./);
@@ -1405,7 +1405,7 @@ if (Meteor.isServer) (() => {
{
username: username,
email: email,
password: hashPassword(password)
password: hashPasswordWithSha(password)
},
);
@@ -1432,7 +1432,7 @@ if (Meteor.isServer) (() => {
await Accounts.createUser(
{
email: email,
password: hashPassword('password')
password: hashPasswordWithSha('password')
}
);
await Accounts.sendResetPasswordEmail(userId, email);
@@ -1452,7 +1452,7 @@ if (Meteor.isServer) (() => {
await Accounts.createUser(
{
email: email,
password: hashPassword('password')
password: hashPasswordWithSha('password')
}
);
await Accounts.sendResetPasswordEmail(userId, email);
@@ -1498,12 +1498,12 @@ if (Meteor.isServer) (() => {
await clientConn.callAsync(
"resetPassword",
enrollPasswordToken,
hashPassword("new-password"))
hashPasswordWithSha("new-password"))
);
test.isTrue(
await clientConn.callAsync("login", {
user: { username },
password: hashPassword("new-password")
password: hashPasswordWithSha("new-password")
})
);
@@ -1535,7 +1535,7 @@ if (Meteor.isServer) (() => {
await Meteor.users.updateAsync(userId, { $set: { "services.password.enroll.when": new Date(Date.now() + -35 * 24 * 3600 * 1000) } });
await test.throwsAsync(
async () => await Meteor.callAsync("resetPassword", enrollPasswordToken, hashPassword("new-password")),
async () => await Meteor.callAsync("resetPassword", enrollPasswordToken, hashPasswordWithSha("new-password")),
/Token expired/
);
});
@@ -1544,7 +1544,7 @@ if (Meteor.isServer) (() => {
async test => {
const email = `${ test.id }-intercept@example.com`;
const userId =
await Accounts.createUser({ email: email, password: hashPassword('password') });
await Accounts.createUser({ email: email, password: hashPasswordWithSha('password') });
await Accounts.sendEnrollmentEmail(userId, email);
const user1 = await Meteor.users.findOneAsync(userId);
@@ -1561,7 +1561,7 @@ if (Meteor.isServer) (() => {
const userId =
await Accounts.createUser({
email: email,
password: hashPassword('password')
password: hashPasswordWithSha('password')
});
await Accounts.sendEnrollmentEmail(userId, email);
@@ -1580,7 +1580,7 @@ if (Meteor.isServer) (() => {
async test => {
const email = `${ test.id }-intercept@example.com`;
const userId =
await Accounts.createUser({ email: email, password: hashPassword('password') });
await Accounts.createUser({ email: email, password: hashPasswordWithSha('password') });
await Accounts.sendResetPasswordEmail(userId, email);
const user1 = await Meteor.users.findOneAsync(userId);
@@ -1727,108 +1727,108 @@ if (Meteor.isServer) (() => {
});
Tinytest.addAsync("passwords - add email when user has not an existing email",
async test => {
const userId = await Accounts.createUser({
username: `user${ Random.id() }`
});
async test => {
const userId = await Accounts.createUser({
username: `user${ Random.id() }`
});
const newEmail = `${ Random.id() }@turing.com`;
await Accounts.addEmailAsync(userId, newEmail);
const u1 = await Accounts._findUserByQuery({ id: userId })
test.equal(u1.emails, [
{ address: newEmail, verified: false },
]);
});
const newEmail = `${ Random.id() }@turing.com`;
await Accounts.addEmailAsync(userId, newEmail);
const u1 = await Accounts._findUserByQuery({ id: userId })
test.equal(u1.emails, [
{ address: newEmail, verified: false },
]);
});
Tinytest.addAsync("passwords - add email when the user has an existing email " +
"only differing in case",
async test => {
const origEmail = `${ Random.id() }@turing.com`;
const userId = await Accounts.createUser({
email: origEmail
const origEmail = `${ Random.id() }@turing.com`;
const userId = await Accounts.createUser({
email: origEmail
});
const newEmail = `${ Random.id() }@turing.com`;
await Accounts.addEmailAsync(userId, newEmail);
const thirdEmail = origEmail.toUpperCase();
await Accounts.addEmailAsync(userId, thirdEmail, true);
const u1 = await Accounts._findUserByQuery({ id: userId })
test.equal(u1.emails, [
{ address: thirdEmail, verified: true },
{ address: newEmail, verified: false }
]);
});
const newEmail = `${ Random.id() }@turing.com`;
await Accounts.addEmailAsync(userId, newEmail);
const thirdEmail = origEmail.toUpperCase();
await Accounts.addEmailAsync(userId, thirdEmail, true);
const u1 = await Accounts._findUserByQuery({ id: userId })
test.equal(u1.emails, [
{ address: thirdEmail, verified: true },
{ address: newEmail, verified: false }
]);
});
Tinytest.addAsync("passwords - add email should fail when there is an existing " +
"user with an email only differing in case",
async test => {
const user1Email = `${ Random.id() }@turing.com`;
const userId1 = await Accounts.createUser({
email: user1Email
const user1Email = `${ Random.id() }@turing.com`;
const userId1 = await Accounts.createUser({
email: user1Email
});
const user2Email = `${ Random.id() }@turing.com`;
const userId2 = await Accounts.createUser({
email: user2Email
});
const dupEmail = user1Email.toUpperCase();
await test.throwsAsync(
async () => await Accounts.addEmailAsync(userId2, dupEmail),
/Email already exists/
);
const u1 = await Accounts._findUserByQuery({ id: userId1 })
test.equal(u1.emails, [
{ address: user1Email, verified: false }
]);
const u2 = await Accounts._findUserByQuery({ id: userId2 })
test.equal(u2.emails, [
{ address: user2Email, verified: false }
]);
});
const user2Email = `${ Random.id() }@turing.com`;
const userId2 = await Accounts.createUser({
email: user2Email
});
const dupEmail = user1Email.toUpperCase();
await test.throwsAsync(
async () => await Accounts.addEmailAsync(userId2, dupEmail),
/Email already exists/
);
const u1 = await Accounts._findUserByQuery({ id: userId1 })
test.equal(u1.emails, [
{ address: user1Email, verified: false }
]);
const u2 = await Accounts._findUserByQuery({ id: userId2 })
test.equal(u2.emails, [
{ address: user2Email, verified: false }
]);
});
Tinytest.addAsync("passwords - remove email",
async test => {
const origEmail = `${ Random.id() }@turing.com`;
const userId = await Accounts.createUser({
email: origEmail
const origEmail = `${ Random.id() }@turing.com`;
const userId = await Accounts.createUser({
email: origEmail
});
const newEmail = `${ Random.id() }@turing.com`;
await Accounts.addEmailAsync(userId, newEmail);
const thirdEmail = `${ Random.id() }@turing.com`;
await Accounts.addEmailAsync(userId, thirdEmail, true);
const u1 = await Accounts._findUserByQuery({ id: userId })
test.equal(u1.emails, [
{ address: origEmail, verified: false },
{ address: newEmail, verified: false },
{ address: thirdEmail, verified: true }
]);
await Accounts.removeEmail(userId, newEmail);
const u2 = await Accounts._findUserByQuery({ id: userId })
test.equal(u2.emails, [
{ address: origEmail, verified: false },
{ address: thirdEmail, verified: true }
]);
await Accounts.removeEmail(userId, origEmail);
const u3 = await Accounts._findUserByQuery({ id: userId })
test.equal(u3.emails, [
{ address: thirdEmail, verified: true }
]);
});
const newEmail = `${ Random.id() }@turing.com`;
await Accounts.addEmailAsync(userId, newEmail);
const thirdEmail = `${ Random.id() }@turing.com`;
await Accounts.addEmailAsync(userId, thirdEmail, true);
const u1 = await Accounts._findUserByQuery({ id: userId })
test.equal(u1.emails, [
{ address: origEmail, verified: false },
{ address: newEmail, verified: false },
{ address: thirdEmail, verified: true }
]);
await Accounts.removeEmail(userId, newEmail);
const u2 = await Accounts._findUserByQuery({ id: userId })
test.equal(u2.emails, [
{ address: origEmail, verified: false },
{ address: thirdEmail, verified: true }
]);
await Accounts.removeEmail(userId, origEmail);
const u3 = await Accounts._findUserByQuery({ id: userId })
test.equal(u3.emails, [
{ address: thirdEmail, verified: true }
]);
});
const getUserHashRounds = user =>
Number(user.services.password.bcrypt.substring(4, 6));
testAsyncMulti("passwords - allow custom bcrypt rounds",[
async function (test) {
// Verify that a bcrypt hash generated for a new account uses the
let username = Random.id();
this.password = hashPassword('abc123');
this.password = hashPasswordWithSha('abc123');
this.userId1 = await Accounts.createUser({ username, password: this.password });
this.user1 = await Meteor.users.findOneAsync(this.userId1);
let rounds = getUserHashRounds(this.user1);
@@ -1876,26 +1876,26 @@ if (Meteor.isServer) (() => {
Tinytest.addAsync('passwords - extra params in email urls',
async (test) => {
const username = Random.id();
const email = `${ username }-intercept@example.com`;
const username = Random.id();
const email = `${ username }-intercept@example.com`;
const userId = await Accounts.createUser({
username: username,
email: email
const userId = await Accounts.createUser({
username: username,
email: email
});
const extraParams = { test: 'success' };
await Accounts.sendEnrollmentEmail(userId, email, null, extraParams);
const [enrollPasswordEmailOptions] =
await Meteor.callAsync("getInterceptedEmails", email);
const re = new RegExp(`${Meteor.absoluteUrl()}(\\S*)`);
const match = enrollPasswordEmailOptions.text.match(re);
const url = new URL(match)
test.equal(url.searchParams.get('test'), extraParams.test);
});
const extraParams = { test: 'success' };
await Accounts.sendEnrollmentEmail(userId, email, null, extraParams);
const [enrollPasswordEmailOptions] =
await Meteor.callAsync("getInterceptedEmails", email);
const re = new RegExp(`${Meteor.absoluteUrl()}(\\S*)`);
const match = enrollPasswordEmailOptions.text.match(re);
const url = new URL(match)
test.equal(url.searchParams.get('test'), extraParams.test);
});
Tinytest.addAsync('passwords - createUserAsync', async test => {
const username = Random.id();
const email = `${username}-intercept@example.com`;

View File

@@ -5,7 +5,8 @@ function isPromise(obj) {
waitUntil = function _waitUntil(checkFunction, { timeout = 15_000, interval = 200, leading = true, description = '' } = {}) {
let waitTime = interval;
return new Promise((resolve, reject) => {
if (leading && checkFunction()) {
const shouldWait = checkFunction();
if (leading && !isPromise(shouldWait) && shouldWait) {
resolve();
return;
}

View File

@@ -772,11 +772,74 @@ authentication. In addition to the basic username and password-based
sign-in process, it also supports email-based sign-in including
address verification and password recovery emails.
The Meteor server stores passwords using the
[bcrypt](http://en.wikipedia.org/wiki/Bcrypt) algorithm. This helps
### Password encryption and security
Starting from `accounts-passwords:4.0.0`, you can choose which algorithm is used by the Meteor server to store passwords : either [bcrypt](http://en.wikipedia.org/wiki/Bcrypt) or
[Argon2](http://en.wikipedia.org/wiki/Argon2) algorithm. Both are robust and contribute to
protect against embarrassing password leaks if the server's database is
compromised.
Before version 4.0.0, `bcrypt` was the only available option. argon2 has been introduced because it is considered the most secure option. This algorithm is specifically designed to resist GPU-based brute force attacks. For more details, see the [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html).
As of January 2025, **`bcrypt` is still the default option** to enable a smooth transition. In the future, `argon2` will replace `bcrypt` as default and `bcrypt` option will be deprecated.
Passwords are hashed on the client using **SHA-256** algorithm before being sent to the server. This ensures that sensitive data is never transmitted in plain text. Once received by the server, the hashed value is further encrypted and securely stored in the `Meteor.users` collection.
**About the migration process from `bcrypt` to `argon2`**
The transition from `bcrypt` to `argon2` happens automatically upon user login. If Argon2 encryption is enabled in an existing application, each user's password is re-encrypted during their next successful login.
- Step 1: The password is first validated against the existing `bcrypt` hash.
- Step 2: If authentication succeeds, the password is re-encrypted using `Argon2`.
- Step 3: The new `Argon2` hash replaces the old `bcrypt` hash in the database.
To monitor the migration progress, you can count users still using bcrypt:
```js
const bcryptUsers = await Meteor.users.find({ "services.password.bcrypt": { $exists: true } }).countAsync();
const totalUsers = await Meteor.users.find({ "services.password": { $exists: true } }).countAsync();
console.log("Remaining users to migrate:", bcryptUsers, "/", totalUsers);
```
Once `bcryptUsers` reaches 0, the migration is complete.
**Enabling Argon2 encryption**
To enable Argon2 encryption, you need a small configuration change on the server:
```js
Accounts.config({
argon2Enabled: true,
});
```
**Configuring `argon2` parameters**
One enabled, the `accounts-password` package allows customization of Argon2's parameters. The configurable options include:
- `type`: `argon2id` (provides a blend of resistance against GPU and side-channel attacks)
- `timeCost` (default: 3) This controls the computational cost of the hashing process, affecting both the security level and performance.
- `memoryCost`: 65536 (64 MB) - The amount of memory used by the algorithm in KiB per thread
- `parallelism`: 4 - The number of threads used by the algorithm
To update the values, use the following configuration:
```js
Accounts.config({
argon2Enabled: true,
argon2Type: "argon2id",
argon2TimeCost: 4,
argon2MemoryCost: 65536,
argon2Parallelism: 4,
});
```
Other Argon2 parameters, such as `hashLength`, are kept to default values:
- `hashLength`: 32 bytes - The length of the hash output in bytes
For more information about Argon2's parameters, refer to the [argon2 options documentation](https://github.com/ranisalt/node-argon2/wiki/Options).
### Using passwords
To add password support to your application, run this command in your terminal:
```bash