From 0f52a1a92197526ca51bd0f402eec6a10d170b57 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 6 Mar 2023 12:16:30 +1100 Subject: [PATCH 1/9] feat: create TOTP adapter --- package-lock.json | 33 +++++ package.json | 1 + spec/AuthenticationAdaptersV2.spec.js | 119 ++++++++++++++++ src/Adapters/Auth/index.js | 2 + src/Adapters/Auth/mfa.js | 196 ++++++++++++++++++++++++++ src/index.js | 1 - 6 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 src/Adapters/Auth/mfa.js diff --git a/package-lock.json b/package-lock.json index 7207bf941d..add915e3fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "mime": "3.0.0", "mongodb": "4.10.0", "mustache": "4.2.0", + "otpauth": "9.0.2", "parse": "4.0.1", "path-to-regexp": "0.1.7", "pg-monitor": "2.0.0", @@ -10048,6 +10049,14 @@ "extsprintf": "^1.2.0" } }, + "node_modules/jssha": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.0.tgz", + "integrity": "sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==", + "engines": { + "node": "*" + } + }, "node_modules/jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -15735,6 +15744,17 @@ "node": ">=8" } }, + "node_modules/otpauth": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.0.2.tgz", + "integrity": "sha512-0TzpkJYg24VvIK3/K91HKpTtMlwm73UoThhcGY8fZsXcwHDrqf008rfdOjj3NnQuyuT11+vHyyO//qRzi6OZ9A==", + "dependencies": { + "jssha": "~3.3.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, "node_modules/p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", @@ -28008,6 +28028,11 @@ } } }, + "jssha": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.0.tgz", + "integrity": "sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==" + }, "jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -32293,6 +32318,14 @@ } } }, + "otpauth": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.0.2.tgz", + "integrity": "sha512-0TzpkJYg24VvIK3/K91HKpTtMlwm73UoThhcGY8fZsXcwHDrqf008rfdOjj3NnQuyuT11+vHyyO//qRzi6OZ9A==", + "requires": { + "jssha": "~3.3.0" + } + }, "p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", diff --git a/package.json b/package.json index 3495ae0e0d..bd0b869606 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "mime": "3.0.0", "mongodb": "4.10.0", "mustache": "4.2.0", + "otpauth": "9.0.2", "parse": "4.0.1", "path-to-regexp": "0.1.7", "pg-monitor": "2.0.0", diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index 4c63b9a1ef..de194826fc 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1272,4 +1272,123 @@ describe('Auth Adapter features', () => { await user.fetch({ useMasterKey: true }); expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } }); }); + it('can create TOTP 2fa adapter', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + const response = user.get('authDataResponse'); + expect(response.mfa).toBeDefined(); + expect(response.mfa.recovery).toBeDefined(); + expect(response.mfa.recovery.length).toEqual(2); + + await user.fetch({ sessionToken: user.getSessionToken() }); + + const res = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: totp.generate(), + }, + }), + }); + + expect(res.data.objectId).toEqual(user.id); + + const new_secret = new OTPAuth.Secret(); + const new_totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: new_secret, + }); + const new_token = new_totp.generate(); + await user.save( + { authData: { mfa: { secret: new_secret.base32, token: new_token, old: totp.generate() } } }, + { sessionToken: user.getSessionToken() } + ); + }); + + it('can create SMS 2fa adapter', async () => { + let code; + let mobile; + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['SMS'], + sendSMS(smsCode, number) { + expect(smsCode).toBeDefined(); + expect(number).toBeDefined(); + expect(smsCode.length).toEqual(6); + code = smsCode; + mobile = number; + }, + digits: 6, + period: 30, + }, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + await user.save( + { authData: { mfa: { mobile: '+11111111111' } } }, + { sessionToken: user.getSessionToken() } + ); + + await user.save( + { authData: { mfa: { mobile, token: code } } }, + { sessionToken: user.getSessionToken() } + ); + + const res = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: true, + }, + }), + }).catch(e => e.data); + expect(res).toEqual({ code: Parse.Error.SCRIPT_FAILED, error: 'Please enter the token' }); + await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: code, + }, + }), + }); + }); }); diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 3440208ebd..4fede1c438 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -9,6 +9,7 @@ const facebook = require('./facebook'); const instagram = require('./instagram'); const linkedin = require('./linkedin'); const meetup = require('./meetup'); +import mfa from './mfa'; const google = require('./google'); const github = require('./github'); const twitter = require('./twitter'); @@ -44,6 +45,7 @@ const providers = { instagram, linkedin, meetup, + mfa, google, github, twitter, diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js new file mode 100644 index 0000000000..e6db6a894d --- /dev/null +++ b/src/Adapters/Auth/mfa.js @@ -0,0 +1,196 @@ +import { TOTP, Secret } from 'otpauth'; +import { randomString } from '../../cryptoUtils'; +import AuthAdapter from './AuthAdapter'; +class MFAAdapter extends AuthAdapter { + constructor() { + super(); + this.policy = 'additional'; + } + validateOptions(opts) { + const validOptions = opts.options; + if (!Array.isArray(validOptions)) { + throw 'mfa.options must be an array'; + } + this.sms = validOptions.includes('SMS'); + this.totp = validOptions.includes('TOTP'); + if (!this.sms && !this.totp) { + throw 'mfa.options must include SMS or TOTP'; + } + const digits = opts.digits || 6; + const period = opts.period || 30; + if (typeof digits !== 'number') { + throw 'mfa.digits must be a number'; + } + if (typeof period !== 'number') { + throw 'mfa.period must be a number'; + } + if (digits < 4 || digits > 10) { + throw 'mfa.digits must be between 4 and 10'; + } + if (period < 10) { + throw 'mfa.period must be greater than 10'; + } + const sendSMS = opts.sendSMS; + if (this.sms && typeof sendSMS !== 'function') { + throw 'mfa.sendSMS callback must be defined when using SMS OTPs'; + } + this.smsCallback = sendSMS; + this.digits = digits; + this.period = period; + this.algorithm = opts.algorithm || 'SHA1'; + } + validateSetUp(mfaData) { + if (mfaData.mobile && this.sms) { + return this.setupMobileOTP(mfaData.mobile); + } + if (this.totp) { + return this.setupTOTP(mfaData); + } + throw 'Invalid MFA data'; + } + async validateLogin(token, _, req) { + const saveResponse = { + doNotSave: true, + }; + const auth = req.original.get('authData') || {}; + const { secret, recovery, mobile, token: saved, expiry } = auth.mfa || {}; + if (this.sms && mobile) { + if (typeof token === 'boolean') { + const { token: sendToken, expiry } = await this.sendSMS(mobile); + auth.mfa.token = sendToken; + auth.mfa.expiry = expiry; + req.object.set('authData', auth); + await req.object.save(null, { useMasterKey: true }); + throw 'Please enter the token'; + } + if (!saved || token !== saved) { + throw 'Invalid MFA token 1'; + } + if (new Date() > expiry) { + throw 'Invalid MFA token 2'; + } + delete auth.mfa.token; + delete auth.mfa.expiry; + return { + save: auth.mfa, + }; + } + if (this.totp) { + if (typeof token !== 'string') { + throw 'Invalid MFA token'; + } + if (!secret) { + return saveResponse; + } + if (recovery[0] === token || recovery[1] === token) { + return saveResponse; + } + const totp = new TOTP({ + algorithm: this.algorithm, + digits: this.digits, + period: this.period, + secret: Secret.fromBase32(secret), + }); + const valid = totp.validate({ + token, + }); + if (valid === null) { + throw 'Invalid MFA token'; + } + } + return saveResponse; + } + validateUpdate(authData, _, req) { + if (req.master) { + return; + } + if (authData.mobile && this.sms) { + if (!authData.token) { + throw 'MFA is already set up on this account'; + } + return this.confirmSMSOTP(authData, req.original.get('authData')?.mfa || {}); + } + if (this.totp) { + this.validateLogin(authData.old, null, req); + return this.validateSetUp(authData); + } + throw 'Invalid MFA data'; + } + afterFind() { + return { + enabled: true, + }; + } + + async setupMobileOTP(mobile) { + const { token, expiry } = await this.sendSMS(mobile); + return { + save: { + pending: { + [mobile]: { + token, + expiry, + }, + }, + }, + }; + } + + async sendSMS(mobile) { + if (!/^[+]*[(]{0,1}[0-9]{1,3}[)]{0,1}[-\s\./0-9]*$/g.test(mobile)) { + throw 'Invalid mobile number.'; + } + let token = ''; + while (token.length < this.digits) { + token += randomString(10).replace(/\D/g, ''); + } + token = token.substring(0, this.digits); + await Promise.resolve(this.smsCallback(token, mobile)); + const expiry = new Date(new Date().getTime() + this.period * 1000); + return { token, expiry }; + } + + async confirmSMSOTP(inputData, authData) { + const { mobile, token } = inputData; + if (!authData.pending?.[mobile]) { + throw 'This number is not pending'; + } + const pendingData = authData.pending[mobile]; + if (token !== pendingData.token) { + throw 'Invalid MFA token'; + } + if (new Date() > pendingData.expiry) { + throw 'Invalid MFA token'; + } + delete authData.pending[mobile]; + authData.mobile = mobile; + return { + save: authData, + }; + } + + setupTOTP(mfaData) { + const { secret, token } = mfaData; + if (!secret || !token || secret.length < 20) { + throw 'Invalid MFA data'; + } + const totp = new TOTP({ + algorithm: this.algorithm, + digits: this.digits, + period: this.period, + secret: Secret.fromBase32(secret), + }); + const valid = totp.validate({ + token, + }); + if (valid === null) { + throw 'Invalid MFA token'; + } + const recovery = [randomString(30), randomString(30)]; + return { + response: { recovery }, + save: { secret, recovery }, + }; + } +} +export default new MFAAdapter(); diff --git a/src/index.js b/src/index.js index dcfe9b4c7e..0c9069d6b5 100644 --- a/src/index.js +++ b/src/index.js @@ -7,7 +7,6 @@ import LRUCacheAdapter from './Adapters/Cache/LRUCache.js'; import * as TestUtils from './TestUtils'; import * as SchemaMigrations from './SchemaMigrations/Migrations'; import AuthAdapter from './Adapters/Auth/AuthAdapter'; - import { useExternal } from './deprecated'; import { getLogger } from './logger'; import { PushWorker } from './Push/PushWorker'; From 3a03fb1715dbe1adf50952adbecd491dab05fcb8 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 6 Mar 2023 13:11:55 +1100 Subject: [PATCH 2/9] wip --- src/Adapters/Auth/mfa.js | 9 ++++++--- src/Auth.js | 10 ++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js index e6db6a894d..b025dc69a1 100644 --- a/src/Adapters/Auth/mfa.js +++ b/src/Adapters/Auth/mfa.js @@ -117,9 +117,12 @@ class MFAAdapter extends AuthAdapter { throw 'Invalid MFA data'; } afterFind() { - return { - enabled: true, - }; + // if (req.master) { + return; + // } + // return { + // enabled: true, + // }; } async setupMobileOTP(mobile) { diff --git a/src/Auth.js b/src/Auth.js index abd14391db..a938e0d994 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -422,14 +422,8 @@ const handleAuthDataValidation = async (authData, req, foundUser) => { await user.fetch({ useMasterKey: true }); } - const { originalObject, updatedObject } = req.buildParseObjects(); - const requestObject = getRequestObject( - undefined, - req.auth, - updatedObject, - originalObject || user, - req.config - ); + const { updatedObject } = req.buildParseObjects(); + const requestObject = getRequestObject(undefined, req.auth, updatedObject, user, req.config); // Perform validation as step-by-step pipeline for better error consistency // and also to avoid to trigger a provider (like OTP SMS) if another one fails const acc = { authData: {}, authDataResponse: {} }; From 4dedafca6a6d8cdc6e50b8da98a0e3034eef765a Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 6 Mar 2023 13:25:42 +1100 Subject: [PATCH 3/9] Update mfa.js --- src/Adapters/Auth/mfa.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js index b025dc69a1..93e99391ce 100644 --- a/src/Adapters/Auth/mfa.js +++ b/src/Adapters/Auth/mfa.js @@ -116,13 +116,13 @@ class MFAAdapter extends AuthAdapter { } throw 'Invalid MFA data'; } - afterFind() { - // if (req.master) { - return; - // } - // return { - // enabled: true, - // }; + afterFind(req) { + if (req.master) { + return; + } + return { + enabled: true, + }; } async setupMobileOTP(mobile) { From 2e32f75fb2928ea20d30c3c305081da4c2c75177 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 6 Mar 2023 14:30:17 +1100 Subject: [PATCH 4/9] refactor tests --- spec/AuthenticationAdapters.spec.js | 280 ++++++++++++++++++++++++++ spec/AuthenticationAdaptersV2.spec.js | 119 ----------- src/Adapters/Auth/index.js | 13 +- src/Adapters/Auth/mfa.js | 14 +- 4 files changed, 300 insertions(+), 126 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index bb89596cef..942c2b4e44 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -2406,3 +2406,283 @@ describe('facebook limited auth adapter', () => { } }); }); + +describe('OTP TOTP auth adatper', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + beforeEach(async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + }); + + it('can enroll', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + const response = user.get('authDataResponse'); + expect(response.mfa).toBeDefined(); + expect(response.mfa.recovery).toBeDefined(); + expect(response.mfa.recovery.length).toEqual(2); + await user.fetch(); + expect(user.get('authData').mfa).toEqual({ enabled: true }); + }); + + it('can login with valid token', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: totp.generate(), + }, + }), + }).then(res => res.data); + expect(response.objectId).toEqual(user.id); + expect(response.sessionToken).toBeDefined(); + expect(response.authData).toEqual({ mfa: { enabled: true } }); + expect(Object.keys(response)).toEqual([ + 'objectId', + 'username', + 'createdAt', + 'updatedAt', + 'authData', + 'ACL', + 'sessionToken', + 'authDataResponse', + ]); + }); + + it('can change OTP with valid token', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + + const new_secret = new OTPAuth.Secret(); + const new_totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: new_secret, + }); + const new_token = new_totp.generate(); + await user.save( + { + authData: { mfa: { secret: new_secret.base32, token: new_token, old: totp.generate() } }, + }, + { sessionToken: user.getSessionToken() } + ); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toEqual(new_secret.base32); + }); + + it('future logins require TOTP token', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + await expectAsync(Parse.User.logIn('username', 'password')).toBeRejectedWith( + new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing additional authData mfa') + ); + }); + + it('future logins reject incorrect TOTP token', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + await expectAsync( + request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: 'abcd', + }, + }), + }).catch(e => { + throw e.data; + }) + ).toBeRejectedWith({ code: Parse.Error.SCRIPT_FAILED, error: 'Invalid MFA token' }); + }); +}); + +describe('OTP SMS auth adatper', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + let code; + let mobile; + const mfa = { + enabled: true, + options: ['SMS'], + sendSMS(smsCode, number) { + expect(smsCode).toBeDefined(); + expect(number).toBeDefined(); + expect(smsCode.length).toEqual(6); + code = smsCode; + mobile = number; + }, + digits: 6, + period: 30, + }; + beforeEach(async () => { + code = ''; + mobile = ''; + await reconfigureServer({ + auth: { + mfa, + }, + }); + }); + + it('can enroll', async () => { + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const spy = spyOn(mfa, 'sendSMS').and.callThrough(); + await user.save({ authData: { mfa: { mobile: '+11111111111' } } }, { sessionToken }); + await user.fetch({ sessionToken }); + expect(user.get('authData')).toEqual({ mfa: { enabled: false } }); + expect(spy).toHaveBeenCalledWith(code, '+11111111111'); + await user.fetch({ useMasterKey: true }); + const authData = user.get('authData').mfa?.pending; + expect(authData).toBeDefined(); + expect(authData['+11111111111']).toBeDefined(); + expect(Object.keys(authData['+11111111111'])).toEqual(['token', 'expiry']); + + await user.save({ authData: { mfa: { mobile, token: code } } }, { sessionToken }); + await user.fetch({ sessionToken }); + expect(user.get('authData')).toEqual({ mfa: { enabled: true } }); + }); + + it('future logins require SMS code', async () => { + const user = await Parse.User.signUp('username', 'password'); + const spy = spyOn(mfa, 'sendSMS').and.callThrough(); + await user.save( + { authData: { mfa: { mobile: '+11111111111' } } }, + { sessionToken: user.getSessionToken() } + ); + + await user.save( + { authData: { mfa: { mobile, token: code } } }, + { sessionToken: user.getSessionToken() } + ); + + spy.calls.reset(); + + const res = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: true, + }, + }), + }).catch(e => e.data); + expect(res).toEqual({ code: Parse.Error.SCRIPT_FAILED, error: 'Please enter the token' }); + expect(spy).toHaveBeenCalledWith(code, '+11111111111'); + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: code, + }, + }), + }).then(res => res.data); + expect(response.objectId).toEqual(user.id); + expect(response.sessionToken).toBeDefined(); + expect(response.authData).toEqual({ mfa: { enabled: true } }); + expect(Object.keys(response)).toEqual([ + 'objectId', + 'username', + 'createdAt', + 'updatedAt', + 'authData', + 'ACL', + 'sessionToken', + 'authDataResponse', + ]); + }); +}); diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index 5f793733e2..9507691114 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1285,123 +1285,4 @@ describe('Auth Adapter features', () => { await user.fetch({ useMasterKey: true }); expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } }); }); - it('can create TOTP 2fa adapter', async () => { - await reconfigureServer({ - auth: { - mfa: { - enabled: true, - options: ['TOTP'], - algorithm: 'SHA1', - digits: 6, - period: 30, - }, - }, - }); - const user = await Parse.User.signUp('username', 'password'); - const OTPAuth = require('otpauth'); - const secret = new OTPAuth.Secret(); - const totp = new OTPAuth.TOTP({ - algorithm: 'SHA1', - digits: 6, - period: 30, - secret, - }); - const token = totp.generate(); - await user.save( - { authData: { mfa: { secret: secret.base32, token } } }, - { sessionToken: user.getSessionToken() } - ); - const response = user.get('authDataResponse'); - expect(response.mfa).toBeDefined(); - expect(response.mfa.recovery).toBeDefined(); - expect(response.mfa.recovery.length).toEqual(2); - - await user.fetch({ sessionToken: user.getSessionToken() }); - - const res = await request({ - headers, - method: 'POST', - url: 'http://localhost:8378/1/login', - body: JSON.stringify({ - username: 'username', - password: 'password', - authData: { - mfa: totp.generate(), - }, - }), - }); - - expect(res.data.objectId).toEqual(user.id); - - const new_secret = new OTPAuth.Secret(); - const new_totp = new OTPAuth.TOTP({ - algorithm: 'SHA1', - digits: 6, - period: 30, - secret: new_secret, - }); - const new_token = new_totp.generate(); - await user.save( - { authData: { mfa: { secret: new_secret.base32, token: new_token, old: totp.generate() } } }, - { sessionToken: user.getSessionToken() } - ); - }); - - it('can create SMS 2fa adapter', async () => { - let code; - let mobile; - await reconfigureServer({ - auth: { - mfa: { - enabled: true, - options: ['SMS'], - sendSMS(smsCode, number) { - expect(smsCode).toBeDefined(); - expect(number).toBeDefined(); - expect(smsCode.length).toEqual(6); - code = smsCode; - mobile = number; - }, - digits: 6, - period: 30, - }, - }, - }); - const user = await Parse.User.signUp('username', 'password'); - await user.save( - { authData: { mfa: { mobile: '+11111111111' } } }, - { sessionToken: user.getSessionToken() } - ); - - await user.save( - { authData: { mfa: { mobile, token: code } } }, - { sessionToken: user.getSessionToken() } - ); - - const res = await request({ - headers, - method: 'POST', - url: 'http://localhost:8378/1/login', - body: JSON.stringify({ - username: 'username', - password: 'password', - authData: { - mfa: true, - }, - }), - }).catch(e => e.data); - expect(res).toEqual({ code: Parse.Error.SCRIPT_FAILED, error: 'Please enter the token' }); - await request({ - headers, - method: 'POST', - url: 'http://localhost:8378/1/login', - body: JSON.stringify({ - username: 'username', - password: 'password', - authData: { - mfa: code, - }, - }), - }); - }); }); diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 55d1a20b88..dc83cdf299 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -227,17 +227,20 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) { if (!authAdapter) { return; } - const { - adapter: { afterFind }, - providerOptions, - } = authAdapter; + const { adapter, providerOptions } = authAdapter; + const afterFind = adapter.afterFind; if (afterFind && typeof afterFind === 'function') { const requestObject = { ip: req.config.ip, user: req.auth.user, master: req.auth.isMaster, }; - const result = afterFind(requestObject, authData[provider], providerOptions); + const result = afterFind.call( + adapter, + requestObject, + authData[provider], + providerOptions + ); if (result) { authData[provider] = result; } diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js index 93e99391ce..67c4a8440a 100644 --- a/src/Adapters/Auth/mfa.js +++ b/src/Adapters/Auth/mfa.js @@ -116,12 +116,22 @@ class MFAAdapter extends AuthAdapter { } throw 'Invalid MFA data'; } - afterFind(req) { + afterFind(req, authData) { if (req.master) { return; } + if (this.totp && authData.secret) { + return { + enabled: true, + }; + } + if (this.sms && authData.mobile) { + return { + enabled: true, + }; + } return { - enabled: true, + enabled: false, }; } From cbb1635047f6970a12ec347e09a23a62c46fffb0 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 6 Mar 2023 15:03:13 +1100 Subject: [PATCH 5/9] add tests --- spec/AuthenticationAdapters.spec.js | 11 +++++++++++ src/Adapters/Auth/AuthAdapter.js | 4 +++- src/Adapters/Auth/index.js | 6 +++++- src/Adapters/Auth/mfa.js | 11 +++++++---- src/Auth.js | 12 +++++++++++- src/RestWrite.js | 1 + src/Routers/UsersRouter.js | 7 ++++++- 7 files changed, 44 insertions(+), 8 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 942c2b4e44..5378b0f31b 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -2645,6 +2645,9 @@ describe('OTP SMS auth adatper', () => { spy.calls.reset(); + await expectAsync(Parse.User.logIn('username', 'password')).toBeRejectedWith( + new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing additional authData mfa') + ); const res = await request({ headers, method: 'POST', @@ -2685,4 +2688,12 @@ describe('OTP SMS auth adatper', () => { 'authDataResponse', ]); }); + + it('partially enrolled users can still login', async () => { + const user = await Parse.User.signUp('username', 'password'); + await user.save({ authData: { mfa: { mobile: '+11111111111' } } }); + const spy = spyOn(mfa, 'sendSMS').and.callThrough(); + await Parse.User.logIn('username', 'password'); + expect(spy).not.toHaveBeenCalled(); + }); }); diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 5b18c75170..e739df3f54 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -21,7 +21,9 @@ export class AuthAdapter { * Usage policy * @type {AuthPolicy} */ - this.policy = 'default'; + if (!this.policy) { + this.policy = 'default'; + } } /** * @param appIds The specified app IDs in the configuration diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index dc83cdf299..da65b24ba5 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -77,7 +77,11 @@ function authDataValidator(provider, adapter, appIds, options) { if (appIds && typeof adapter.validateAppId === 'function') { await Promise.resolve(adapter.validateAppId(appIds, authData, options, requestObject)); } - if (adapter.policy && !authAdapterPolicies[adapter.policy]) { + if ( + adapter.policy && + !authAdapterPolicies[adapter.policy] && + typeof adapter.policy !== 'function' + ) { throw new Parse.Error( Parse.Error.OTHER_CAUSE, 'AuthAdapter policy is not configured correctly. The value must be either "solo", "additional", "default" or undefined (will be handled as "default")' diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js index 67c4a8440a..efcff7b633 100644 --- a/src/Adapters/Auth/mfa.js +++ b/src/Adapters/Auth/mfa.js @@ -2,10 +2,6 @@ import { TOTP, Secret } from 'otpauth'; import { randomString } from '../../cryptoUtils'; import AuthAdapter from './AuthAdapter'; class MFAAdapter extends AuthAdapter { - constructor() { - super(); - this.policy = 'additional'; - } validateOptions(opts) { const validOptions = opts.options; if (!Array.isArray(validOptions)) { @@ -135,6 +131,13 @@ class MFAAdapter extends AuthAdapter { }; } + policy(req, auth) { + if (this.sms && auth?.pending && Object.keys(auth).length === 1) { + return 'default'; + } + return 'additional'; + } + async setupMobileOTP(mobile) { const { token, expiry } = await this.sendSMS(mobile); return { diff --git a/src/Auth.js b/src/Auth.js index a938e0d994..ceb14a5de9 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -362,6 +362,7 @@ const hasMutatedAuthData = (authData, userAuthData) => { }; const checkIfUserHasProvidedConfiguredProvidersForLogin = ( + req = {}, authData = {}, userAuthData = {}, config @@ -385,7 +386,16 @@ const checkIfUserHasProvidedConfiguredProvidersForLogin = ( const additionProvidersNotFound = []; const hasProvidedAtLeastOneAdditionalProvider = savedUserProviders.some(provider => { - if (provider && provider.adapter && provider.adapter.policy === 'additional') { + let policy = provider.adapter.policy; + if (typeof policy === 'function') { + const requestObject = { + ip: req.config.ip, + user: req.auth.user, + master: req.auth.isMaster, + }; + policy = policy.call(provider.adapter, requestObject, userAuthData[provider.name]); + } + if (policy === 'additional') { if (authData[provider.name]) { return true; } else { diff --git a/src/RestWrite.js b/src/RestWrite.js index 3a8385e52a..62f736c464 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -550,6 +550,7 @@ RestWrite.prototype.handleAuthData = async function (authData) { // we need to be sure that the user has provided // required authData Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( + { config: this.config.ip, auth: this.auth }, authData, userResult.authData, this.config diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index feca46e802..1174ae53ce 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -189,7 +189,12 @@ export class UsersRouter extends ClassesRouter { const user = await this._authenticateUserFromRequest(req); const authData = req.body && req.body.authData; // Check if user has provided their required auth providers - Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(authData, user.authData, req.config); + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( + req, + authData, + user.authData, + req.config + ); let authDataResponse; let validatedAuthData; From b2440a69561dea23aac6f59bf9245b29c23fdc12 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 6 Mar 2023 15:06:42 +1100 Subject: [PATCH 6/9] Update RestWrite.js --- src/RestWrite.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RestWrite.js b/src/RestWrite.js index 62f736c464..2c39f06b88 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -550,7 +550,7 @@ RestWrite.prototype.handleAuthData = async function (authData) { // we need to be sure that the user has provided // required authData Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( - { config: this.config.ip, auth: this.auth }, + { config: this.config, auth: this.auth }, authData, userResult.authData, this.config From f61cc53a6cd376485146630b9342c2d03e059ffc Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 6 Mar 2023 19:24:35 +1100 Subject: [PATCH 7/9] Update AuthenticationAdapters.spec.js --- spec/AuthenticationAdapters.spec.js | 46 ++++++++++++++++------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 5378b0f31b..21a4e10d7b 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -2480,16 +2480,18 @@ describe('OTP TOTP auth adatper', () => { expect(response.objectId).toEqual(user.id); expect(response.sessionToken).toBeDefined(); expect(response.authData).toEqual({ mfa: { enabled: true } }); - expect(Object.keys(response)).toEqual([ - 'objectId', - 'username', - 'createdAt', - 'updatedAt', - 'authData', - 'ACL', - 'sessionToken', - 'authDataResponse', - ]); + expect(Object.keys(response).sort()).toEqual( + [ + 'objectId', + 'username', + 'createdAt', + 'updatedAt', + 'authData', + 'ACL', + 'sessionToken', + 'authDataResponse', + ].sort() + ); }); it('can change OTP with valid token', async () => { @@ -2630,7 +2632,7 @@ describe('OTP SMS auth adatper', () => { expect(user.get('authData')).toEqual({ mfa: { enabled: true } }); }); - it('future logins require SMS code', async () => { + fit('future logins require SMS code', async () => { const user = await Parse.User.signUp('username', 'password'); const spy = spyOn(mfa, 'sendSMS').and.callThrough(); await user.save( @@ -2677,16 +2679,18 @@ describe('OTP SMS auth adatper', () => { expect(response.objectId).toEqual(user.id); expect(response.sessionToken).toBeDefined(); expect(response.authData).toEqual({ mfa: { enabled: true } }); - expect(Object.keys(response)).toEqual([ - 'objectId', - 'username', - 'createdAt', - 'updatedAt', - 'authData', - 'ACL', - 'sessionToken', - 'authDataResponse', - ]); + expect(Object.keys(response).sort()).toEqual( + [ + 'objectId', + 'username', + 'createdAt', + 'updatedAt', + 'authData', + 'ACL', + 'sessionToken', + 'authDataResponse', + ].sort() + ); }); it('partially enrolled users can still login', async () => { From 5b7f6341e0bb66861e6b8f5872ce758602cc3b36 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 6 Mar 2023 19:28:23 +1100 Subject: [PATCH 8/9] Update AuthenticationAdapters.spec.js --- spec/AuthenticationAdapters.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 21a4e10d7b..e11220a63c 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -2632,7 +2632,7 @@ describe('OTP SMS auth adatper', () => { expect(user.get('authData')).toEqual({ mfa: { enabled: true } }); }); - fit('future logins require SMS code', async () => { + it('future logins require SMS code', async () => { const user = await Parse.User.signUp('username', 'password'); const spy = spyOn(mfa, 'sendSMS').and.callThrough(); await user.save( From f6f3e85f5d1250a3bceef4cb2443682364afb6b6 Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 23 Jun 2023 13:27:41 +1000 Subject: [PATCH 9/9] Update package-lock.json --- package-lock.json | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 0ddcbdbeb2..8664502816 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "mime": "3.0.0", "mongodb": "4.10.0", "mustache": "4.2.0", + "otpauth": "9.0.2", "parse": "4.1.0", "path-to-regexp": "6.2.1", "pg-monitor": "2.0.0", @@ -46,7 +47,7 @@ "pluralize": "8.0.0", "rate-limit-redis": "3.0.2", "redis": "4.6.6", - "semver": "^7.5.2", + "semver": "7.5.2", "subscriptions-transport-ws": "0.11.0", "tv4": "1.3.0", "uuid": "9.0.0", @@ -10220,6 +10221,14 @@ "extsprintf": "^1.2.0" } }, + "node_modules/jssha": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.0.tgz", + "integrity": "sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==", + "engines": { + "node": "*" + } + }, "node_modules/jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -15940,6 +15949,17 @@ "node": ">=8" } }, + "node_modules/otpauth": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.0.2.tgz", + "integrity": "sha512-0TzpkJYg24VvIK3/K91HKpTtMlwm73UoThhcGY8fZsXcwHDrqf008rfdOjj3NnQuyuT11+vHyyO//qRzi6OZ9A==", + "dependencies": { + "jssha": "~3.3.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, "node_modules/p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", @@ -28397,6 +28417,11 @@ } } }, + "jssha": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.0.tgz", + "integrity": "sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==" + }, "jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -32712,6 +32737,14 @@ } } }, + "otpauth": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.0.2.tgz", + "integrity": "sha512-0TzpkJYg24VvIK3/K91HKpTtMlwm73UoThhcGY8fZsXcwHDrqf008rfdOjj3NnQuyuT11+vHyyO//qRzi6OZ9A==", + "requires": { + "jssha": "~3.3.0" + } + }, "p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",