Skip to content

Commit 8c087dd

Browse files
author
EC2 Default User
committed
Ensure userId that generated challenge is the same as userId verifying it
1 parent bc3ad7e commit 8c087dd

File tree

3 files changed

+64
-21
lines changed

3 files changed

+64
-21
lines changed

README.md

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -195,18 +195,13 @@ This package exposes the `MFA.generateChallenge` and `MFA.verifyChallenge` metho
195195
````
196196
Meteor.methods({
197197
"start:disableMFA":function () {
198-
let challenge = MFA.generateChallenge(this.userId, "disableMFA");
198+
let challenge = MFA.generateChallenge(this.userId, "disableMFA", MFA.generateConnectionHash(this.connection));
199199
return challenge;
200200
},
201201
202202
"complete:disableMFA":function (solvedChallenge) {
203-
let isValid = MFA.verifyChallenge("disableMFA", solvedChallenge);
204-
205-
if(!isValid) {
206-
throw new Meteor.Error(404, "MFA Challenge Failed");
207-
}
208-
209-
MFA.disableMFA(this.userId);
203+
let userId = MFA.verifyChallenge(this.userId, "disableMFA", MFA.generateConnectionHash(this.connection), solvedChallenge);
204+
MFA.disableMFA(userId);
210205
}
211206
});
212207
````
@@ -381,15 +376,23 @@ Disables MFA for a user. This is an internal method. If you'd like the user to a
381376
#### MFA.disablePasswordless(userId)
382377
Disables passwordless for a user. When this method is called, MFA will remain enabled. To disable passwordless and MFA, call `MFA.disableMFA`.
383378

384-
#### MFA.generateChallenge(userId, type)
385-
Generates a challenge. This is then sent to the client and passed into `MFA.solveChallenge()`
379+
#### MFA.generateChallenge(userId, type, connectionHash)
380+
Generates a challenge. This is then sent to the client and passed into `MFA.solveChallenge()`. See connectionHash below.
381+
382+
#### MFA.verifyChallenge(userId, type, connectionHash, solvedChallenge)
383+
Verifies the solvedChallenge (created by `MFA.solveChallenge` on client). Throws an error on failiure, returns the userId that generated the challenge on success.
386384

387-
#### MFA.verifyChallenge(type, solvedChallenge)
388-
Verifies the solvedChallenge (created by `MFA.solveChallenge` on client).
385+
The userId argument is used to verify that the user that you generated the challenge for is the same as the user you are verifying the challenge for, so you should typically pass in this.userId, or however you can get the current user id. In situations where you are performing this kind of verification in another way, set userId to null.
389386

390387
#### MFA.enableDebug()
391388
Enables debugging
392389

390+
#### MFA.generateConnectionHash(connection)
391+
Creates a connection hash per the config.
392+
393+
#### connectionHash
394+
The connection hash ensures that the same device that creates the challenge is the one that verifies/uses it. When using `MFA.generateChallenge` or `MFA.verifyChallenge`, you can use the `MFA.generateConnectionHash` method to create one, or if you do not need it you can pass an empty string.
395+
393396
### Config Options
394397

395398
**mfaDetailsField *String* (default: "mfa")** The field where the mfa status object is stored (this is the field you can publish to tell whether a user has enabled)

mfa-server.js

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ let _defaults = {
6262
allowU2FAuthorization:true,
6363
authorizationDisabledMethods:[],
6464
keepChallenges:false,
65-
passwordless:false
65+
passwordless:false,
66+
ignoreNullUserIdVerifyChallengeTypes:[]
6667
};
6768

6869
let config = Object.assign({}, _defaults);
@@ -92,13 +93,17 @@ let isExpired = function (expiryDate) {
9293
return new Date() >= expiryDate;
9394
};
9495

95-
let generateChallenge = function (userId, type, challengeConnectionHash) {
96+
const generateChallenge = function (userId, type, challengeConnectionHash) {
9697
let user = Meteor.users.findOne({_id:userId}, {fields:{"services.mfapublickey":1, "services.mfaenabled":1, "services.mfamethod":1}});
9798

9899
if(!user || !user.services.mfaenabled) {
99100
throw new Meteor.Error(400);
100101
}
101102

103+
if(!challengeConnectionHash && !(challengeConnectionHash === null && type === "login")) {
104+
throw new Error("MFA.generateChallenge: Missing connectionHash. If you don't want to use a hash, pass an empty string when generating and validating a chalenge.");
105+
}
106+
102107
check(user.services.mfamethod, Match.OneOf(...mfaMethods));
103108

104109
let response, challengeData;
@@ -203,6 +208,27 @@ let u2fAuthorizationIsValid = function (authorization) {
203208
return authorization instanceof Object && !authorization.used && !isExpired(authorization.expires) && authorization.authenticated === true;
204209
};
205210

211+
const nullUserIdVerifyChallengeError = type => `
212+
WARNING: you are passing null as userId for verifyChallenge
213+
This means that verifyChallenge will not ensure that the current userId
214+
is the same as the userId that the challenge was generated for.
215+
216+
This can be a major security vulnerability, as a challenge solved by one
217+
user can be used as a solved challenge for a different user.
218+
219+
You should only use null for userId if you are preventing this kind of
220+
vulnerability yourself. For example, using the userId value returned by
221+
MFA.verifyChallenge().
222+
223+
To stop this warning from appearing, set
224+
config.ignoreNullUserIdVerifyChallengeTypes to an array that includes ${type}:
225+
226+
MFA.setConfig({ignoreNullUserIdVerifyChallengeTypes:["${type}"]});
227+
228+
This error will be thrown in development, but will only be a warning in
229+
production.
230+
`;
231+
206232
let invalidateU2FAuthorization = function (authorizationId) {
207233
MFAAuthorizations.update({_id:authorizationId}, {$set:{
208234
used:true,
@@ -220,21 +246,35 @@ const invalidateChallenge = function (challengeId) {
220246
}
221247
};
222248

223-
const verifyChallenge = function (type, params) {
249+
const verifyChallenge = function (userId, type, challengeConnectionHash, params) {
224250
let {challengeId, challengeSecret} = params;
225251
check(challengeId, String);
226252
check(challengeSecret, String);
227253

228-
let challengeConnectionHash = createConnectionHash(this.connection);
254+
if(!challengeConnectionHash) {
255+
challengeConnectionHash = createConnectionHash(this.connection);
256+
}
229257
let challengeObj = MFAChallenges.findOne({_id:challengeId});
230258

259+
if(userId === null && type !== "login") {
260+
let err = nullUserIdVerifyChallengeError(type);
261+
262+
if(Meteor.isDevelopment) {
263+
throw new Error(err);
264+
}
265+
else {
266+
console.warn(err);
267+
}
268+
}
269+
231270
if(
232271
!challengeObj
233272
|| challengeObj.type !== type
234273
|| challengeObj.connectionHash !== challengeConnectionHash
235274
|| challengeObj.challengeSecret !== challengeSecret
236275
|| challengeObj.expires < new Date()
237276
|| challengeObj.used === true
277+
|| (challengeObj.userId !== userId && userId !== null)
238278
) {
239279
throw new Meteor.Error(404);
240280
}
@@ -488,7 +528,7 @@ Meteor.methods({
488528
check(params.challengeId, String);
489529
check(params.challengeSecret, String);
490530

491-
let userId = verifyChallenge("login", params);
531+
let userId = verifyChallenge(null, "login", createConnectionHash(this.connection), params);
492532

493533
return Accounts._attemptLogin(this, 'login', '', {
494534
type: 'mfa',
@@ -544,7 +584,7 @@ Meteor.methods({
544584
throw new Meteor.Error(400);
545585
}
546586

547-
verifyChallenge(("authorize:" + authorizationId), solvedU2FChallenge);
587+
verifyChallenge(this.userId, ("authorize:" + authorizationId), createConnectionHash(this.connection), solvedU2FChallenge);
548588

549589
let code = generateCode();
550590

@@ -584,7 +624,7 @@ Accounts.validateLoginAttempt(options => {
584624
let params = options.methodArguments[2];
585625

586626
try {
587-
verifyChallenge("resetPassword", params);
627+
verifyChallenge(options.user._id, "resetPassword", createConnectionHash(options.connection), params);
588628
return true;
589629
}
590630
catch(e) {
@@ -607,4 +647,4 @@ let getCurrentTOTP = function (secret) {
607647
return authenticator.generate(secret);
608648
};
609649

610-
export default { disablePasswordless, verifyChallenge, getCurrentTOTP, enableOTP, setConfig, setStrings, disableMFA, generateChallenge, verifyAssertion, verifyAttestation:verifyAssertion };
650+
export default { generateConnectionHash:createConnectionHash, disablePasswordless, verifyChallenge, getCurrentTOTP, enableOTP, setConfig, setStrings, disableMFA, generateChallenge, verifyAssertion, verifyAttestation:verifyAssertion };

package.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Package.describe({
22
name: 'ndev:mfa',
3-
version: '0.0.14',
3+
version: '0.1.0',
44
summary: 'Multi Factor Authentication and Passwordless (supporting U2F, TOTP, and OTP)',
55
git: 'https://github.com/TheRealNate/meteor-mfa',
66
documentation: 'README.md'

0 commit comments

Comments
 (0)