Skip to content

Commit 8d7cb6d

Browse files
committed
Initial feature for totp
1 parent 3851641 commit 8d7cb6d

File tree

8 files changed

+206
-116
lines changed

8 files changed

+206
-116
lines changed

package-lock.json

Lines changed: 72 additions & 105 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"lru-cache": "5.1.1",
3535
"mime": "2.4.0",
3636
"mongodb": "3.2.0",
37+
"otplib": "^10.0.1",
3738
"parse": "2.1.0",
3839
"pg-promise": "8.5.5",
3940
"redis": "2.8.0",

spec/.eslintrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
"range": true,
2424
"jequal": true,
2525
"create": true,
26-
"arrayContains": true
26+
"arrayContains": true,
27+
"expectAsync": true
2728
},
2829
"rules": {
2930
"no-console": [0],

spec/ParseUser.spec.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageA
1212
const request = require('../lib/request');
1313
const passwordCrypto = require('../lib/password');
1414
const Config = require('../lib/Config');
15+
const otplib = require('otplib');
1516

1617
function verifyACL(user) {
1718
const ACL = user.getACL();
@@ -3754,3 +3755,45 @@ describe('Parse.User testing', () => {
37543755
);
37553756
});
37563757
});
3758+
3759+
function enable2FA(user) {
3760+
return request({
3761+
method: 'GET',
3762+
url: 'http://localhost:8378/1/users/me/enable2FA',
3763+
json: true,
3764+
headers: {
3765+
'X-Parse-Session-Token': user.getSessionToken(),
3766+
'X-Parse-Application-Id': Parse.applicationId,
3767+
'X-Parse-REST-API-Key': 'rest',
3768+
},
3769+
});
3770+
}
3771+
3772+
function validate2FA(user, token) {
3773+
return request({
3774+
method: 'POST',
3775+
url: 'http://localhost:8378/1/users/me/verify2FA',
3776+
body: {
3777+
token,
3778+
},
3779+
headers: {
3780+
'X-Parse-Session-Token': user.getSessionToken(),
3781+
'X-Parse-Application-Id': Parse.applicationId,
3782+
'X-Parse-REST-API-Key': 'rest',
3783+
'Content-Type': 'application/json',
3784+
},
3785+
});
3786+
}
3787+
3788+
describe('2FA', () => {
3789+
it('should enable 2FA tokens', async () => {
3790+
const user = await Parse.User.signUp('username', 'password');
3791+
const {
3792+
data: { secret },
3793+
} = await enable2FA(user);
3794+
const token = otplib.authenticator.generate(secret);
3795+
await validate2FA(user, token);
3796+
// await Parse.User.logOut();
3797+
await expectAsync(Parse.User.logIn('username', 'password')).toBeRejected();
3798+
});
3799+
});

src/Adapters/Storage/Mongo/MongoTransform.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const transformKey = (className, fieldName, schema) => {
1818
return '_last_used';
1919
case 'timesUsed':
2020
return 'times_used';
21+
case 'mfa':
22+
return '_mfa';
2123
}
2224

2325
if (
@@ -106,6 +108,9 @@ const transformKeyValueForUpdate = (
106108
key = 'times_used';
107109
timeField = true;
108110
break;
111+
case 'mfa':
112+
key = '_mfa';
113+
break;
109114
}
110115

111116
if (
@@ -286,6 +291,7 @@ function transformQueryKeyValue(className, key, value, schema) {
286291
case '_wperm':
287292
case '_perishable_token':
288293
case '_email_verify_token':
294+
case '_mfa':
289295
return { key, value };
290296
case '$or':
291297
case '$and':
@@ -1349,6 +1355,9 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => {
13491355
break;
13501356
case '_acl':
13511357
break;
1358+
case '_mfa':
1359+
restObject._mfa = mongoObject[key];
1360+
break;
13521361
case '_email_verify_token':
13531362
case '_perishable_token':
13541363
case '_perishable_token_expires_at':

src/Adapters/Storage/Postgres/PostgresStorageAdapter.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,7 @@ export class PostgresStorageAdapter implements StorageAdapter {
951951
fields._perishable_token_expires_at = { type: 'Date' };
952952
fields._password_changed_at = { type: 'Date' };
953953
fields._password_history = { type: 'Array' };
954+
fields._mfa = { type: 'String' };
954955
}
955956
let index = 2;
956957
const relations = [];
@@ -1458,6 +1459,10 @@ export class PostgresStorageAdapter implements StorageAdapter {
14581459
update['authData'] = update['authData'] || {};
14591460
update['authData'][provider] = value;
14601461
}
1462+
if (fieldName === 'mfa') {
1463+
update['_mfa'] = update['mfa'];
1464+
delete update.mfa;
1465+
}
14611466
}
14621467

14631468
for (const fieldName in update) {

src/Controllers/DatabaseController.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const specialQuerykeys = [
6363
'_email_verify_token_expires_at',
6464
'_account_lockout_expires_at',
6565
'_failed_login_count',
66+
'_mfa',
6667
];
6768

6869
const isSpecialQueryKey = key => {
@@ -184,6 +185,7 @@ const filterSensitiveData = (isMaster, aclGroup, className, object) => {
184185
delete object._account_lockout_expires_at;
185186
delete object._password_changed_at;
186187
delete object._password_history;
188+
delete object._mfa;
187189

188190
if (aclGroup.indexOf(object.objectId) > -1) {
189191
return object;
@@ -212,6 +214,7 @@ const specialKeysForUpdate = [
212214
'_perishable_token_expires_at',
213215
'_password_changed_at',
214216
'_password_history',
217+
'_mfa',
215218
];
216219

217220
const isSpecialUpdateKey = key => {

src/Routers/UsersRouter.js

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ClassesRouter from './ClassesRouter';
77
import rest from '../rest';
88
import Auth from '../Auth';
99
import passwordCrypto from '../password';
10+
import * as otplib from 'otplib';
1011

1112
export class UsersRouter extends ClassesRouter {
1213
className() {
@@ -44,7 +45,7 @@ export class UsersRouter extends ClassesRouter {
4445
) {
4546
payload = req.query;
4647
}
47-
const { username, email, password } = payload;
48+
const { username, email, password, token } = payload;
4849

4950
// TODO: use the right error codes / descriptions.
5051
if (!username && !email) {
@@ -153,7 +154,12 @@ export class UsersRouter extends ClassesRouter {
153154
delete user.authData;
154155
}
155156
}
156-
157+
if (user._mfa) {
158+
if (!otplib.authenticator.verify({ token, secret: user._mfa })) {
159+
throw new Parse.Error(-1, 'Invalid 2FA token');
160+
}
161+
}
162+
delete user._mfa;
157163
return resolve(user);
158164
})
159165
.catch(error => {
@@ -189,16 +195,15 @@ export class UsersRouter extends ClassesRouter {
189195
Parse.Error.INVALID_SESSION_TOKEN,
190196
'Invalid session token'
191197
);
192-
} else {
193-
const user = response.results[0].user;
194-
// Send token back on the login, because SDKs expect that.
195-
user.sessionToken = sessionToken;
198+
}
199+
const user = response.results[0].user;
200+
// Send token back on the login, because SDKs expect that.
201+
user.sessionToken = sessionToken;
196202

197-
// Remove hidden properties.
198-
UsersRouter.removeHiddenProperties(user);
203+
// Remove hidden properties.
204+
UsersRouter.removeHiddenProperties(user);
199205

200-
return { response: user };
201-
}
206+
return { response: user };
202207
});
203208
}
204209

@@ -310,6 +315,60 @@ export class UsersRouter extends ClassesRouter {
310315
return Promise.resolve(success);
311316
}
312317

318+
async enable2FA(req) {
319+
const { user } = req.auth;
320+
const secret = otplib.authenticator.generateSecret();
321+
const otpauth = otplib.authenticator.keyuri(
322+
user.username,
323+
'service',
324+
secret
325+
);
326+
await rest.update(
327+
req.config,
328+
req.auth,
329+
'_User',
330+
{
331+
objectId: user.id,
332+
},
333+
{
334+
mfa: `pending:${secret}`,
335+
}
336+
);
337+
return { response: { qrcodeURL: otpauth, secret } };
338+
}
339+
340+
async verify2FA(req) {
341+
const { token } = req.body;
342+
// Fetch the user directly from the DB as we need the _mfa
343+
const [user] = await req.config.database.find('_User', {
344+
objectId: req.auth.user.id,
345+
});
346+
const mfa = user._mfa;
347+
if (!mfa) {
348+
throw new Parse.Error(-1, 'MFA is not enabled on this account');
349+
}
350+
if (mfa.indexOf('pending:') !== 0) {
351+
throw new Parse.Error(-1, 'MFA is already active');
352+
}
353+
const secret = mfa.slice('pending:'.length);
354+
const result = otplib.authenticator.verify({ token, secret });
355+
if (!result) {
356+
throw new Parse.Error(-1, 'Invalid token');
357+
}
358+
await rest.update(
359+
req.config,
360+
req.auth,
361+
'_User',
362+
{
363+
objectId: req.auth.user.id,
364+
},
365+
{
366+
mfa: `${secret}`,
367+
}
368+
);
369+
return { response: {} };
370+
}
371+
313372
_throwOnBadEmailConfig(req) {
314373
try {
315374
Config.validateEmailConfiguration({
@@ -441,6 +500,8 @@ export class UsersRouter extends ClassesRouter {
441500
this.route('POST', '/logout', req => {
442501
return this.handleLogOut(req);
443502
});
503+
this.route('GET', '/users/me/enable2FA', req => this.enable2FA(req));
504+
this.route('POST', '/users/me/verify2FA', req => this.verify2FA(req));
444505
this.route('POST', '/requestPasswordReset', req => {
445506
return this.handleResetRequest(req);
446507
});

0 commit comments

Comments
 (0)