diff --git a/app/2fa/client/TOTPOAuth.js b/app/2fa/client/TOTPOAuth.js index 76f328667c8..685c28eaa18 100644 --- a/app/2fa/client/TOTPOAuth.js +++ b/app/2fa/client/TOTPOAuth.js @@ -6,11 +6,17 @@ import { Twitter } from 'meteor/twitter-oauth'; import { MeteorDeveloperAccounts } from 'meteor/meteor-developer-oauth'; import { Linkedin } from 'meteor/pauli:linkedin-oauth'; import { OAuth } from 'meteor/oauth'; +import s from 'underscore.string'; import { Utils2fa } from './lib/2fa'; +import { process2faReturn } from './callWithTwoFactorRequired'; +import { CustomOAuth } from '../../custom-oauth'; -Accounts.oauth.tryLoginAfterPopupClosed = function(credentialToken, callback, totpCode) { - const credentialSecret = OAuth._retrieveCredentialSecret(credentialToken) || null; +let lastCredentialToken = null; +let lastCredentialSecret = null; + +Accounts.oauth.tryLoginAfterPopupClosed = function(credentialToken, callback, totpCode, credentialSecret = null) { + credentialSecret = credentialSecret || OAuth._retrieveCredentialSecret(credentialToken) || null; const methodArgument = { oauth: { credentialToken, @@ -18,6 +24,9 @@ Accounts.oauth.tryLoginAfterPopupClosed = function(credentialToken, callback, to }, }; + lastCredentialToken = credentialToken; + lastCredentialSecret = credentialSecret; + if (totpCode && typeof totpCode === 'string') { methodArgument.totp = { code: totpCode, @@ -41,32 +50,89 @@ Accounts.oauth.credentialRequestCompleteHandler = function(callback, totpCode) { }; }; -const loginWithFacebookAndTOTP = Utils2fa.createOAuthTotpLoginMethod(() => Facebook); +const createOAuthTotpLoginMethod = (credentialProvider) => (options, code, callback) => { + // support a callback without options + if (!callback && typeof options === 'function') { + callback = options; + options = null; + } + + if (lastCredentialToken && lastCredentialSecret) { + Accounts.oauth.tryLoginAfterPopupClosed(lastCredentialToken, callback, code, lastCredentialSecret); + } else { + const provider = (credentialProvider && credentialProvider()) || this; + const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback, code); + provider.requestCredential(options, credentialRequestCompleteCallback); + } + + lastCredentialToken = null; + lastCredentialSecret = null; +}; + +const loginWithOAuthTokenAndTOTP = createOAuthTotpLoginMethod(); + +const loginWithFacebookAndTOTP = createOAuthTotpLoginMethod(() => Facebook); const { loginWithFacebook } = Meteor; Meteor.loginWithFacebook = function(options, cb) { Utils2fa.overrideLoginMethod(loginWithFacebook, [options], cb, loginWithFacebookAndTOTP); }; -const loginWithGithubAndTOTP = Utils2fa.createOAuthTotpLoginMethod(() => Github); +const loginWithGithubAndTOTP = createOAuthTotpLoginMethod(() => Github); const { loginWithGithub } = Meteor; Meteor.loginWithGithub = function(options, cb) { Utils2fa.overrideLoginMethod(loginWithGithub, [options], cb, loginWithGithubAndTOTP); }; -const loginWithMeteorDeveloperAccountAndTOTP = Utils2fa.createOAuthTotpLoginMethod(() => MeteorDeveloperAccounts); +const loginWithMeteorDeveloperAccountAndTOTP = createOAuthTotpLoginMethod(() => MeteorDeveloperAccounts); const { loginWithMeteorDeveloperAccount } = Meteor; Meteor.loginWithMeteorDeveloperAccount = function(options, cb) { Utils2fa.overrideLoginMethod(loginWithMeteorDeveloperAccount, [options], cb, loginWithMeteorDeveloperAccountAndTOTP); }; -const loginWithTwitterAndTOTP = Utils2fa.createOAuthTotpLoginMethod(() => Twitter); +const loginWithTwitterAndTOTP = createOAuthTotpLoginMethod(() => Twitter); const { loginWithTwitter } = Meteor; Meteor.loginWithTwitter = function(options, cb) { Utils2fa.overrideLoginMethod(loginWithTwitter, [options], cb, loginWithTwitterAndTOTP); }; -const loginWithLinkedinAndTOTP = Utils2fa.createOAuthTotpLoginMethod(() => Linkedin); +const loginWithLinkedinAndTOTP = createOAuthTotpLoginMethod(() => Linkedin); const { loginWithLinkedin } = Meteor; Meteor.loginWithLinkedin = function(options, cb) { Utils2fa.overrideLoginMethod(loginWithLinkedin, [options], cb, loginWithLinkedinAndTOTP); }; + +Accounts.onPageLoadLogin((loginAttempt) => { + if (loginAttempt?.error?.error !== 'totp-required') { + return; + } + + const { methodArguments } = loginAttempt; + if (!methodArguments?.length) { + return; + } + + const oAuthArgs = methodArguments.find((arg) => arg.oauth); + const { credentialToken, credentialSecret } = oAuthArgs.oauth; + const cb = loginAttempt.userCallback; + + process2faReturn({ + error: loginAttempt.error, + originalCallback: cb, + onCode: (code) => { + Accounts.oauth.tryLoginAfterPopupClosed(credentialToken, cb, code, credentialSecret); + }, + }); +}); + +const oldConfigureLogin = CustomOAuth.prototype.configureLogin; +CustomOAuth.prototype.configureLogin = function(...args) { + const loginWithService = `loginWith${ s.capitalize(this.name) }`; + + oldConfigureLogin.apply(this, args); + + const oldMethod = Meteor[loginWithService]; + + Meteor[loginWithService] = function(options, cb) { + Utils2fa.overrideLoginMethod(oldMethod, [options], cb, loginWithOAuthTokenAndTOTP); + }; +}; diff --git a/app/2fa/client/lib/2fa.js b/app/2fa/client/lib/2fa.js index 3fbb112f47d..bef17e6979e 100644 --- a/app/2fa/client/lib/2fa.js +++ b/app/2fa/client/lib/2fa.js @@ -1,9 +1,7 @@ import { Meteor } from 'meteor/meteor'; import toastr from 'toastr'; -import s from 'underscore.string'; import { Accounts } from 'meteor/accounts-base'; -import { CustomOAuth } from '../../../custom-oauth'; import { t } from '../../../utils/client'; import { process2faReturn } from '../callWithTwoFactorRequired'; @@ -36,8 +34,9 @@ export class Utils2fa { originalCallback: cb, onCode: (code) => { loginMethodTOTP && loginMethodTOTP.apply(this, loginArgs.concat([code, (error) => { - console.log('failed'); - console.log(error); + if (error) { + console.log(error); + } if (error && error.error === 'totp-invalid') { toastr.error(t('Invalid_two_factor_code')); cb(); @@ -49,42 +48,4 @@ export class Utils2fa { }); }])); } - - static createOAuthTotpLoginMethod(credentialProvider) { - return function(options, code, callback) { - // support a callback without options - if (!callback && typeof options === 'function') { - callback = options; - options = null; - } - - const provider = credentialProvider(); - - const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback, code); - provider.requestCredential(options, credentialRequestCompleteCallback); - }; - } } - -const oldConfigureLogin = CustomOAuth.prototype.configureLogin; -CustomOAuth.prototype.configureLogin = function(...args) { - const loginWithService = `loginWith${ s.capitalize(this.name) }`; - - oldConfigureLogin.apply(this, args); - - const oldMethod = Meteor[loginWithService]; - const newMethod = (options, code, callback) => { - // support a callback without options - if (!callback && typeof options === 'function') { - callback = options; - options = null; - } - - const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback, code); - this.requestCredential(options, credentialRequestCompleteCallback); - }; - - Meteor[loginWithService] = function(options, cb) { - Utils2fa.overrideLoginMethod(oldMethod, [options], cb, newMethod); - }; -}; diff --git a/app/2fa/server/loginHandler.js b/app/2fa/server/loginHandler.js index 49a9b7f56ce..531c87b1a09 100644 --- a/app/2fa/server/loginHandler.js +++ b/app/2fa/server/loginHandler.js @@ -1,4 +1,7 @@ +import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; +import { OAuth } from 'meteor/oauth'; +import { check } from 'meteor/check'; import { callbacks } from '../../callbacks'; import { checkCodeForUser } from './code/index'; @@ -22,3 +25,54 @@ callbacks.add('onValidateLogin', (login) => { return login; }, callbacks.priority.MEDIUM, '2fa'); + +const recreateError = (errorDoc) => { + let error; + + if (errorDoc.meteorError) { + error = new Meteor.Error(); + delete errorDoc.meteorError; + } else { + error = new Error(); + } + + Object.getOwnPropertyNames(errorDoc).forEach((key) => { + error[key] = errorDoc[key]; + }); + return error; +}; + +OAuth._retrievePendingCredential = function(key, ...args) { + const credentialSecret = args.length > 0 && args[0] !== undefined ? args[0] : null; + check(key, String); + + const pendingCredential = OAuth._pendingCredentials.findOne({ + key, + credentialSecret, + }); + + if (!pendingCredential) { + return; + } + + if (pendingCredential.credential.error) { + OAuth._pendingCredentials.remove({ + _id: pendingCredential._id, + }); + return recreateError(pendingCredential.credential.error); + } + + // Work-around to make the credentials reusable for 2FA + const future = new Date(); + future.setMinutes(future.getMinutes() + 2); + + OAuth._pendingCredentials.update({ + _id: pendingCredential._id, + }, { + $set: { + createdAt: future, + }, + }); + + return OAuth.openSecret(pendingCredential.credential); +};