From 5fc920c732c61c9d2929ec0e199f001e7188dd52 Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 24 Feb 2023 13:09:01 +1100 Subject: [PATCH 1/7] otp wip --- package-lock.json | 57 ++++++++++++++++++++------- package.json | 1 + spec/AuthenticationAdaptersV2.spec.js | 31 +++++++++++++++ src/Adapters/Auth/index.js | 3 ++ src/Adapters/Auth/mfa.js | 13 ++++++ 5 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 src/Adapters/Auth/mfa.js diff --git a/package-lock.json b/package-lock.json index b0207bda87..95886f3b98 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": "1.5.0", @@ -10242,6 +10243,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", @@ -11679,12 +11688,14 @@ } }, "node_modules/mock-files-adapter": { - "resolved": "spec/dependencies/mock-files-adapter", - "link": true + "version": "1.0.0", + "resolved": "file:spec/dependencies/mock-files-adapter", + "dev": true }, "node_modules/mock-mail-adapter": { - "resolved": "spec/dependencies/mock-mail-adapter", - "link": true + "version": "1.0.0", + "resolved": "file:spec/dependencies/mock-mail-adapter", + "dev": true }, "node_modules/modify-values": { "version": "1.0.1", @@ -15926,6 +15937,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": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -20720,14 +20742,6 @@ "dependencies": { "zen-observable": "0.8.15" } - }, - "spec/dependencies/mock-files-adapter": { - "version": "1.0.0", - "dev": true - }, - "spec/dependencies/mock-mail-adapter": { - "version": "1.0.0", - "dev": true } }, "dependencies": { @@ -28472,6 +28486,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", @@ -29618,10 +29637,12 @@ } }, "mock-files-adapter": { - "version": "file:spec/dependencies/mock-files-adapter" + "version": "1.0.0", + "dev": true }, "mock-mail-adapter": { - "version": "file:spec/dependencies/mock-mail-adapter" + "version": "1.0.0", + "dev": true }, "modify-values": { "version": "1.0.1", @@ -32757,6 +32778,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": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", diff --git a/package.json b/package.json index 8caf847983..aea2b51758 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": "1.5.0", diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index 244349a89f..c768b04159 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1248,4 +1248,35 @@ describe('Auth Adapter features', () => { await user.fetch({ useMasterKey: true }); expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } }); }); + + fit('can create 2fa adapter', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + algorithm: 'SHA1', + secret: 'NB2W45DFOIZA', + digits: 6, + period: 30, + }, + }, + silent: false, + }); + const user = await Parse.User.signUp('username', 'password'); + await user.save( + { authData: { mfa: { enroll: true } } }, + { sessionToken: user.getSessionToken() } + ); + console.log(user.toJSON()); + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }); + }); }); diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 0338bfdcea..77ad996463 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -8,6 +8,7 @@ const facebook = require('./facebook'); const instagram = require('./instagram'); const linkedin = require('./linkedin'); const meetup = require('./meetup'); +const mfa = require('./mfa'); const google = require('./google'); const github = require('./github'); const twitter = require('./twitter'); @@ -43,6 +44,7 @@ const providers = { instagram, linkedin, meetup, + mfa, google, github, twitter, @@ -88,6 +90,7 @@ function authDataValidator(provider, adapter, appIds, options) { typeof adapter.validateLogin !== 'function' || typeof adapter.validateUpdate !== 'function' ) { + console.log({adapter}); throw new Parse.Error( Parse.Error.OTHER_CAUSE, 'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate' diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js new file mode 100644 index 0000000000..3a38990e52 --- /dev/null +++ b/src/Adapters/Auth/mfa.js @@ -0,0 +1,13 @@ + +export const validateSetUp = (...args) => { + console.log({args}); +} +export const validateLogin = (...args2) => { + console.log({args2}) +} +export const validateUpdate = (...args3) => { + console.log({args3}) +} +export const challenge = () => { + return {token: 'test'}; +} From 1867d73eb09098ff7075d38e97c7b141f05aad4d Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 25 Feb 2023 12:58:20 +1100 Subject: [PATCH 2/7] feat: new TOTP auth adapter --- spec/AuthenticationAdaptersV2.spec.js | 47 +++++++++++++--- src/Adapters/Auth/AuthAdapter.js | 10 ++++ src/Adapters/Auth/index.js | 56 ++++++++++++++---- src/Adapters/Auth/mfa.js | 81 +++++++++++++++++++++++---- src/RestQuery.js | 12 ++++ src/Routers/UsersRouter.js | 2 + src/index.js | 2 + 7 files changed, 180 insertions(+), 30 deletions(-) diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index c768b04159..a211cc103d 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1249,7 +1249,7 @@ describe('Auth Adapter features', () => { expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } }); }); - fit('can create 2fa adapter', async () => { + it('can create 2fa adapter', async () => { await reconfigureServer({ auth: { mfa: { @@ -1260,23 +1260,54 @@ describe('Auth Adapter features', () => { period: 30, }, }, - silent: false, }); 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: { enroll: true } } }, + { authData: { mfa: { secret: secret.base32, token } } }, { sessionToken: user.getSessionToken() } ); - console.log(user.toJSON()); + 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: headers, + headers, method: 'POST', - url: 'http://localhost:8378/1/challenge', + url: 'http://localhost:8378/1/login', body: JSON.stringify({ - challengeData: { - challengeAdapter: { someData: true }, + 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() } + ); }); }); diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 0e106014d5..58ede1a428 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -93,6 +93,16 @@ export class AuthAdapter { challenge(challengeData, authData, options, request) { return Promise.resolve({}); } + + /** + * Triggered when auth data is fetched + * @param {Object} authData authData + * @param {Object} options additional adapter options + * @returns {Promise} Any overrides required to authData + */ + afterFind(authData, options) { + return Promise.resolve({}); + } } export default AuthAdapter; diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 77ad996463..965763faaf 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -1,5 +1,6 @@ import loadAdapter from '../AdapterLoader'; import Parse from 'parse/node'; +import AuthAdapter from './AuthAdapter'; const apple = require('./apple'); const gcenter = require('./gcenter'); @@ -8,7 +9,7 @@ const facebook = require('./facebook'); const instagram = require('./instagram'); const linkedin = require('./linkedin'); const meetup = require('./meetup'); -const mfa = require('./mfa'); +import mfa from './mfa'; const google = require('./google'); const github = require('./github'); const twitter = require('./twitter'); @@ -90,7 +91,6 @@ function authDataValidator(provider, adapter, appIds, options) { typeof adapter.validateLogin !== 'function' || typeof adapter.validateUpdate !== 'function' ) { - console.log({adapter}); throw new Parse.Error( Parse.Error.OTHER_CAUSE, 'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate' @@ -156,22 +156,31 @@ function loadAuthAdapter(provider, authOptions) { return; } + const keys = [ + 'validateAuthData', + 'validateAppId', + 'validateSetUp', + 'validateLogin', + 'validateUpdate', + 'challenge', + 'policy', + 'afterFind' + ]; const adapter = Object.assign({}, defaultAdapter); + const defaultAuthAdapter = new AuthAdapter(); + keys.forEach(key => { + const existing = defaultAdapter[key]; + if (existing && typeof existing === 'function' && existing.toString() !== defaultAuthAdapter[key].toString()) { + adapter[key] = existing + } + }); const appIds = providerOptions ? providerOptions.appIds : undefined; // Try the configuration methods if (providerOptions) { const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); if (optionalAdapter) { - [ - 'validateAuthData', - 'validateAppId', - 'validateSetUp', - 'validateLogin', - 'validateUpdate', - 'challenge', - 'policy', - ].forEach(key => { + keys.forEach(key => { if (optionalAdapter[key]) { adapter[key] = optionalAdapter[key]; } @@ -198,9 +207,34 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) { return { validator: authDataValidator(provider, adapter, appIds, providerOptions), adapter }; }; + const runAfterFind = async (authData) => { + if (!authData) { + return; + } + const adapters = Object.keys(authData); + await Promise.all( + adapters.map(async provider => { + const authAdapter = getValidatorForProvider(provider); + if (!authAdapter) { + return; + } + const { + adapter: { afterFind }, + } = authAdapter; + if (afterFind && typeof afterFind === 'function') { + const result = afterFind(authData[provider]); + if (result) { + authData[provider] = result; + } + } + }) + ); + } + return Object.freeze({ getValidatorForProvider, setEnableAnonymousUsers, + runAfterFind }); }; diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js index 3a38990e52..17da1a7cba 100644 --- a/src/Adapters/Auth/mfa.js +++ b/src/Adapters/Auth/mfa.js @@ -1,13 +1,72 @@ -export const validateSetUp = (...args) => { - console.log({args}); -} -export const validateLogin = (...args2) => { - console.log({args2}) -} -export const validateUpdate = (...args3) => { - console.log({args3}) -} -export const challenge = () => { - return {token: 'test'}; +import {TOTP, Secret} from 'otpauth' +import { randomString } from '../../cryptoUtils'; +import AuthAdapter from './AuthAdapter'; +class MFAAdapter extends AuthAdapter { + constructor() { + super(); + this.policy = 'additional'; + } + validateSetUp(mfaData, options) { + const {secret, token } = mfaData; + if (!secret || !token || secret.length < 20) { + console.log('invalid', secret, token); + throw 'Invalid MFA data' + } + const totp = new TOTP({ + algorithm: options.algorithm || "SHA1", + digits: options.digits || 6, + period: options.period || 30, + secret: Secret.fromBase32(secret) + }); + const valid = totp.validate({ + token, + }); + if (valid === null) { + console.log({token, secret}) + throw 'Invalid MFA token' + } + const recovery = [randomString(30), randomString(30)]; + return { + response: { recovery }, + save: { secret, recovery }, + } + } + validateLogin(token, options, req) { + if (typeof token !== 'string') { + throw 'Invalid MFA token' + } + const {secret, recovery} = req.original.get('authData')?.mfa || {}; + if (!secret) { + return; + } + if (recovery[0] === token || recovery[1] === token) { + return; + } + const totp = new TOTP({ + algorithm: options.algorithm || "SHA1", + digits: options.digits || 6, + period: options.period || 30, + secret: Secret.fromBase32(secret) + }); + const valid = totp.validate({ + token, + }); + if (valid === null) { + throw 'Invalid MFA token' + } + return { + doNotSave: true + } + } + validateUpdate(authData, options, req) { + this.validateLogin(authData.old, options, req); + return this.validateSetUp(authData, options); + } + afterFind() { + return { + enabled: true + } + } } +export default new MFAAdapter(); diff --git a/src/RestQuery.js b/src/RestQuery.js index f936a5a7a8..c07a7c3a24 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -223,6 +223,9 @@ RestQuery.prototype.execute = function (executeOptions) { .then(() => { return this.runAfterFindTrigger(); }) + .then(() => { + return this.handleAuthAdapters(); + }) .then(() => { return this.response; }); @@ -842,6 +845,15 @@ RestQuery.prototype.runAfterFindTrigger = function () { }); }; +RestQuery.prototype.handleAuthAdapters = async function () { + if (this.className !== '_User') { + return; + } + await Promise.all( + this.response.results.map(result => this.config.authDataManager.runAfterFind(result.authData)) + ); +}; + // Adds included values to the response. // Path is a list of field names. // Returns a promise for an augmented response. diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index a0c0039c47..65cd1ea856 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -293,6 +293,8 @@ export class UsersRouter extends ClassesRouter { user.authDataResponse = authDataResponse; } + await req.config.authDataManager.runAfterFind(user.authData); + return { response: user }; } diff --git a/src/index.js b/src/index.js index 684443ce5b..4456a01827 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,7 @@ import { getLogger } from './logger'; import { PushWorker } from './Push/PushWorker'; import { ParseServerOptions } from './Options'; import { ParseGraphQLServer } from './GraphQL/ParseGraphQLServer'; +import AuthAdapter from './Adapters/Auth/AuthAdapter'; // Factory function const _ParseServer = function (options: ParseServerOptions) { @@ -43,4 +44,5 @@ export { ParseGraphQLServer, _ParseServer as ParseServer, SchemaMigrations, + AuthAdapter, }; From 59f49189f75735cd01506ca99cc80c1b58266ce5 Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 25 Feb 2023 13:24:53 +1100 Subject: [PATCH 3/7] refactor --- package-lock.json | 2 +- package.json | 2 +- src/Adapters/Auth/AuthAdapter.js | 9 --------- src/Adapters/Auth/index.js | 27 +-------------------------- src/Adapters/Auth/mfa.js | 2 -- src/RestQuery.js | 12 ------------ src/Routers/UsersRouter.js | 2 -- 7 files changed, 3 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index 95886f3b98..da0d9715fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "mime": "3.0.0", "mongodb": "4.10.0", "mustache": "4.2.0", - "otpauth": "^9.0.2", + "otpauth": "9.0.2", "parse": "4.0.1", "path-to-regexp": "0.1.7", "pg-monitor": "1.5.0", diff --git a/package.json b/package.json index aea2b51758..67312e7668 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "mime": "3.0.0", "mongodb": "4.10.0", "mustache": "4.2.0", - "otpauth": "^9.0.2", + "otpauth": "9.0.2", "parse": "4.0.1", "path-to-regexp": "0.1.7", "pg-monitor": "1.5.0", diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 58ede1a428..535411dcdd 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -94,15 +94,6 @@ export class AuthAdapter { return Promise.resolve({}); } - /** - * Triggered when auth data is fetched - * @param {Object} authData authData - * @param {Object} options additional adapter options - * @returns {Promise} Any overrides required to authData - */ - afterFind(authData, options) { - return Promise.resolve({}); - } } export default AuthAdapter; diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 965763faaf..e676108389 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -207,34 +207,9 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) { return { validator: authDataValidator(provider, adapter, appIds, providerOptions), adapter }; }; - const runAfterFind = async (authData) => { - if (!authData) { - return; - } - const adapters = Object.keys(authData); - await Promise.all( - adapters.map(async provider => { - const authAdapter = getValidatorForProvider(provider); - if (!authAdapter) { - return; - } - const { - adapter: { afterFind }, - } = authAdapter; - if (afterFind && typeof afterFind === 'function') { - const result = afterFind(authData[provider]); - if (result) { - authData[provider] = result; - } - } - }) - ); - } - return Object.freeze({ getValidatorForProvider, - setEnableAnonymousUsers, - runAfterFind + setEnableAnonymousUsers }); }; diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js index 17da1a7cba..da1b0e9adb 100644 --- a/src/Adapters/Auth/mfa.js +++ b/src/Adapters/Auth/mfa.js @@ -10,7 +10,6 @@ class MFAAdapter extends AuthAdapter { validateSetUp(mfaData, options) { const {secret, token } = mfaData; if (!secret || !token || secret.length < 20) { - console.log('invalid', secret, token); throw 'Invalid MFA data' } const totp = new TOTP({ @@ -23,7 +22,6 @@ class MFAAdapter extends AuthAdapter { token, }); if (valid === null) { - console.log({token, secret}) throw 'Invalid MFA token' } const recovery = [randomString(30), randomString(30)]; diff --git a/src/RestQuery.js b/src/RestQuery.js index c07a7c3a24..f936a5a7a8 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -223,9 +223,6 @@ RestQuery.prototype.execute = function (executeOptions) { .then(() => { return this.runAfterFindTrigger(); }) - .then(() => { - return this.handleAuthAdapters(); - }) .then(() => { return this.response; }); @@ -845,15 +842,6 @@ RestQuery.prototype.runAfterFindTrigger = function () { }); }; -RestQuery.prototype.handleAuthAdapters = async function () { - if (this.className !== '_User') { - return; - } - await Promise.all( - this.response.results.map(result => this.config.authDataManager.runAfterFind(result.authData)) - ); -}; - // Adds included values to the response. // Path is a list of field names. // Returns a promise for an augmented response. diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 65cd1ea856..a0c0039c47 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -293,8 +293,6 @@ export class UsersRouter extends ClassesRouter { user.authDataResponse = authDataResponse; } - await req.config.authDataManager.runAfterFind(user.authData); - return { response: user }; } From 20bcbdeeadbe063704859a4012f9251d42a7d6a3 Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 25 Feb 2023 13:27:51 +1100 Subject: [PATCH 4/7] Update index.js --- src/Adapters/Auth/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index e676108389..9903c6df66 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -169,7 +169,7 @@ function loadAuthAdapter(provider, authOptions) { const adapter = Object.assign({}, defaultAdapter); const defaultAuthAdapter = new AuthAdapter(); keys.forEach(key => { - const existing = defaultAdapter[key]; + const existing = defaultAdapter?.[key]; if (existing && typeof existing === 'function' && existing.toString() !== defaultAuthAdapter[key].toString()) { adapter[key] = existing } From 70db305d0586a8130bcce90eec107816d1e586a2 Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 25 Feb 2023 13:29:52 +1100 Subject: [PATCH 5/7] revert --- src/Adapters/Auth/AuthAdapter.js | 1 - src/Adapters/Auth/index.js | 29 ++++++++++------------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 535411dcdd..0e106014d5 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -93,7 +93,6 @@ export class AuthAdapter { challenge(challengeData, authData, options, request) { return Promise.resolve({}); } - } export default AuthAdapter; diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 9903c6df66..9999f9a179 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -156,31 +156,22 @@ function loadAuthAdapter(provider, authOptions) { return; } - const keys = [ - 'validateAuthData', - 'validateAppId', - 'validateSetUp', - 'validateLogin', - 'validateUpdate', - 'challenge', - 'policy', - 'afterFind' - ]; const adapter = Object.assign({}, defaultAdapter); - const defaultAuthAdapter = new AuthAdapter(); - keys.forEach(key => { - const existing = defaultAdapter?.[key]; - if (existing && typeof existing === 'function' && existing.toString() !== defaultAuthAdapter[key].toString()) { - adapter[key] = existing - } - }); const appIds = providerOptions ? providerOptions.appIds : undefined; // Try the configuration methods if (providerOptions) { const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); if (optionalAdapter) { - keys.forEach(key => { + [ + 'validateAuthData', + 'validateAppId', + 'validateSetUp', + 'validateLogin', + 'validateUpdate', + 'challenge', + 'policy', + ].forEach(key => { if (optionalAdapter[key]) { adapter[key] = optionalAdapter[key]; } @@ -209,7 +200,7 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) { return Object.freeze({ getValidatorForProvider, - setEnableAnonymousUsers + setEnableAnonymousUsers, }); }; From 6c23942ee75bf4e75000d9a76631af9fbca96f19 Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 25 Feb 2023 13:38:04 +1100 Subject: [PATCH 6/7] Update index.js --- src/Adapters/Auth/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 9999f9a179..2b22714492 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -1,6 +1,5 @@ import loadAdapter from '../AdapterLoader'; import Parse from 'parse/node'; -import AuthAdapter from './AuthAdapter'; const apple = require('./apple'); const gcenter = require('./gcenter'); From 79e62d7748c57236de71dd7fd458ab3f2bcf20fa Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 25 Feb 2023 16:12:56 +1100 Subject: [PATCH 7/7] wip --- spec/AuthenticationAdaptersV2.spec.js | 62 +++++++- src/Adapters/Auth/AuthAdapter.js | 8 + src/Adapters/Auth/index.js | 33 +++-- src/Adapters/Auth/mfa.js | 205 +++++++++++++++++++++----- src/Auth.js | 10 +- 5 files changed, 259 insertions(+), 59 deletions(-) diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index a211cc103d..dadaaf51a9 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1249,13 +1249,13 @@ describe('Auth Adapter features', () => { expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } }); }); - it('can create 2fa adapter', async () => { + it('can create TOTP 2fa adapter', async () => { await reconfigureServer({ auth: { mfa: { enabled: true, + options: ['TOTP'], algorithm: 'SHA1', - secret: 'NB2W45DFOIZA', digits: 6, period: 30, }, @@ -1310,4 +1310,62 @@ describe('Auth Adapter features', () => { { 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/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 0e106014d5..bb7983f2b9 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -93,6 +93,14 @@ export class AuthAdapter { challenge(challengeData, authData, options, request) { return Promise.resolve({}); } + + /** + * Triggered when the adapter is first attached to Parse Server + * @param {Object} options Adapter Options + */ + validateOptions(options) { + /* */ + } } export default AuthAdapter; diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 2b22714492..6ce8813d8d 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -1,5 +1,6 @@ import loadAdapter from '../AdapterLoader'; import Parse from 'parse/node'; +import AuthAdapter from './AuthAdapter'; const apple = require('./apple'); const gcenter = require('./gcenter'); @@ -155,28 +156,40 @@ function loadAuthAdapter(provider, authOptions) { return; } - const adapter = Object.assign({}, defaultAdapter); + const keys = [ + 'validateAuthData', + 'validateAppId', + 'validateSetUp', + 'validateLogin', + 'validateUpdate', + 'validateOptions', + 'challenge', + 'policy' + ]; + const adapter = defaultAdapter; + const defaultAuthAdapter = new AuthAdapter(); + keys.forEach(key => { + const existing = adapter?.[key]; + if (existing && typeof existing === 'function' && existing.toString() === defaultAuthAdapter[key].toString()) { + adapter[key] = null; + } + }); const appIds = providerOptions ? providerOptions.appIds : undefined; // Try the configuration methods if (providerOptions) { const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); if (optionalAdapter) { - [ - 'validateAuthData', - 'validateAppId', - 'validateSetUp', - 'validateLogin', - 'validateUpdate', - 'challenge', - 'policy', - ].forEach(key => { + keys.forEach(key => { if (optionalAdapter[key]) { adapter[key] = optionalAdapter[key]; } }); } } + if (adapter.validateOptions) { + adapter.validateOptions(providerOptions); + } return { adapter, appIds, providerOptions }; } diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js index da1b0e9adb..a0fc54ffa5 100644 --- a/src/Adapters/Auth/mfa.js +++ b/src/Adapters/Auth/mfa.js @@ -7,44 +7,178 @@ class MFAAdapter extends AuthAdapter { super(); this.policy = 'additional'; } - validateSetUp(mfaData, options) { - const {secret, token } = mfaData; - if (!secret || !token || secret.length < 20) { - throw 'Invalid MFA data' + validateOptions(opts) { + const validOptions = opts.options; + if (!Array.isArray(validOptions)) { + throw 'mfa.options must be an array' } - const totp = new TOTP({ - algorithm: options.algorithm || "SHA1", - digits: options.digits || 6, - period: options.period || 30, - secret: Secret.fromBase32(secret) - }); - const valid = totp.validate({ - token, - }); - if (valid === null) { - throw 'Invalid MFA token' + this.sms = validOptions.includes('SMS'); + this.totp = validOptions.includes('TOTP'); + if (!this.sms && !this.totp) { + throw 'mfa.options must include SMS or TOTP' } - const recovery = [randomString(30), randomString(30)]; - return { - response: { recovery }, - save: { secret, recovery }, + 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' } - validateLogin(token, options, req) { - if (typeof token !== 'string') { - throw 'Invalid MFA token' + validateSetUp(mfaData) { + if (mfaData.mobile && this.sms) { + return this.setupMobileOTP(mfaData.mobile); } - const {secret, recovery} = req.original.get('authData')?.mfa || {}; - if (!secret) { - return; + 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 (recovery[0] === token || recovery[1] === token) { + 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: options.algorithm || "SHA1", - digits: options.digits || 6, - period: options.period || 30, + algorithm: this.algorithm, + digits: this.digits, + period: this.period, secret: Secret.fromBase32(secret) }); const valid = totp.validate({ @@ -53,17 +187,10 @@ class MFAAdapter extends AuthAdapter { if (valid === null) { throw 'Invalid MFA token' } + const recovery = [randomString(30), randomString(30)]; return { - doNotSave: true - } - } - validateUpdate(authData, options, req) { - this.validateLogin(authData.old, options, req); - return this.validateSetUp(authData, options); - } - afterFind() { - return { - enabled: true + response: { recovery }, + save: { secret, recovery }, } } } 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: {} };