From 378ef37349a718f973bb80b038109fc38b826403 Mon Sep 17 00:00:00 2001
From: "Pierre H. Lehnen" <Hudell@users.noreply.github.com>
Date: Fri, 20 Nov 2020 22:34:01 -0300
Subject: [PATCH] [NEW] 2 Factor Authentication when using OAuth and SAML
 (#11726)

---
 app/2fa/client/TOTPGoogle.js                  | 41 +++++++++
 app/2fa/client/TOTPLDAP.js                    | 50 +++++++++++
 app/2fa/client/TOTPOAuth.js                   | 72 +++++++++++++++
 app/2fa/client/TOTPPassword.js                | 11 +--
 app/2fa/client/TOTPSaml.js                    | 32 +++++++
 app/2fa/client/index.js                       |  4 +
 app/2fa/client/lib/2fa.js                     | 89 +++++++++++++++++++
 app/2fa/server/loginHandler.js                |  6 +-
 app/api/server/api.js                         | 16 +++-
 .../client/saml_client.js                     | 10 +++
 client/routes.js                              | 34 +++----
 packages/rocketchat-i18n/i18n/en.i18n.json    |  2 +
 12 files changed, 338 insertions(+), 29 deletions(-)
 create mode 100644 app/2fa/client/TOTPGoogle.js
 create mode 100644 app/2fa/client/TOTPLDAP.js
 create mode 100644 app/2fa/client/TOTPOAuth.js
 create mode 100644 app/2fa/client/TOTPSaml.js
 create mode 100644 app/2fa/client/lib/2fa.js

diff --git a/app/2fa/client/TOTPGoogle.js b/app/2fa/client/TOTPGoogle.js
new file mode 100644
index 00000000000..24c7c66b690
--- /dev/null
+++ b/app/2fa/client/TOTPGoogle.js
@@ -0,0 +1,41 @@
+import { Meteor } from 'meteor/meteor';
+import { Accounts } from 'meteor/accounts-base';
+import { Google } from 'meteor/google-oauth';
+import _ from 'underscore';
+
+import { Utils2fa } from './lib/2fa';
+
+const loginWithGoogleAndTOTP = function(options, code, callback) {
+	// support a callback without options
+	if (!callback && typeof options === 'function') {
+		callback = options;
+		options = null;
+	}
+
+	if (Meteor.isCordova && Google.signIn) {
+		// After 20 April 2017, Google OAuth login will no longer work from
+		// a WebView, so Cordova apps must use Google Sign-In instead.
+		// https://github.com/meteor/meteor/issues/8253
+		Google.signIn(options, callback);
+		return;
+	} // Use Google's domain-specific login page if we want to restrict creation to
+	// a particular email domain. (Don't use it if restrictCreationByEmailDomain
+	// is a function.) Note that all this does is change Google's UI ---
+	// accounts-base/accounts_server.js still checks server-side that the server
+	// has the proper email address after the OAuth conversation.
+
+
+	if (typeof Accounts._options.restrictCreationByEmailDomain === 'string') {
+		options = _.extend({}, options || {});
+		options.loginUrlParameters = _.extend({}, options.loginUrlParameters || {});
+		options.loginUrlParameters.hd = Accounts._options.restrictCreationByEmailDomain;
+	}
+
+	const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback, code);
+	Google.requestCredential(options, credentialRequestCompleteCallback);
+};
+
+const { loginWithGoogle } = Meteor;
+Meteor.loginWithGoogle = function(options, cb) {
+	Utils2fa.overrideLoginMethod(loginWithGoogle, [options], cb, loginWithGoogleAndTOTP);
+};
diff --git a/app/2fa/client/TOTPLDAP.js b/app/2fa/client/TOTPLDAP.js
new file mode 100644
index 00000000000..512a8068c70
--- /dev/null
+++ b/app/2fa/client/TOTPLDAP.js
@@ -0,0 +1,50 @@
+import { Meteor } from 'meteor/meteor';
+import { Accounts } from 'meteor/accounts-base';
+
+import { Utils2fa } from './lib/2fa';
+
+Meteor.loginWithLDAPAndTOTP = function(...args) {
+	// Pull username and password
+	const username = args.shift();
+	const ldapPass = args.shift();
+
+	// Check if last argument is a function. if it is, pop it off and set callback to it
+	const callback = typeof args[args.length - 1] === 'function' ? args.pop() : null;
+	// The last argument before the callback is the totp code
+	const code = args.pop();
+
+	// if args still holds options item, grab it
+	const ldapOptions = args.length > 0 ? args.shift() : {};
+
+	// Set up loginRequest object
+	const loginRequest = {
+		ldap: true,
+		username,
+		ldapPass,
+		ldapOptions,
+	};
+
+	Accounts.callLoginMethod({
+		methodArguments: [{
+			totp: {
+				login: loginRequest,
+				code,
+			},
+		}],
+		userCallback(error) {
+			if (error) {
+				Utils2fa.reportError(error, callback);
+			} else {
+				callback && callback();
+			}
+		},
+	});
+};
+
+const { loginWithLDAP } = Meteor;
+
+Meteor.loginWithLDAP = function(...args) {
+	const callback = typeof args[args.length - 1] === 'function' ? args.pop() : null;
+
+	Utils2fa.overrideLoginMethod(loginWithLDAP, args, callback, Meteor.loginWithLDAPAndTOTP);
+};
diff --git a/app/2fa/client/TOTPOAuth.js b/app/2fa/client/TOTPOAuth.js
new file mode 100644
index 00000000000..76f328667c8
--- /dev/null
+++ b/app/2fa/client/TOTPOAuth.js
@@ -0,0 +1,72 @@
+import { Meteor } from 'meteor/meteor';
+import { Accounts } from 'meteor/accounts-base';
+import { Facebook } from 'meteor/facebook-oauth';
+import { Github } from 'meteor/github-oauth';
+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 { Utils2fa } from './lib/2fa';
+
+Accounts.oauth.tryLoginAfterPopupClosed = function(credentialToken, callback, totpCode) {
+	const credentialSecret = OAuth._retrieveCredentialSecret(credentialToken) || null;
+	const methodArgument = {
+		oauth: {
+			credentialToken,
+			credentialSecret,
+		},
+	};
+
+	if (totpCode && typeof totpCode === 'string') {
+		methodArgument.totp = {
+			code: totpCode,
+		};
+	}
+
+	Accounts.callLoginMethod({
+		methodArguments: [methodArgument],
+		userCallback: callback && function(err) {
+			callback(Utils2fa.convertError(err));
+		} });
+};
+
+Accounts.oauth.credentialRequestCompleteHandler = function(callback, totpCode) {
+	return function(credentialTokenOrError) {
+		if (credentialTokenOrError && credentialTokenOrError instanceof Error) {
+			callback && callback(credentialTokenOrError);
+		} else {
+			Accounts.oauth.tryLoginAfterPopupClosed(credentialTokenOrError, callback, totpCode);
+		}
+	};
+};
+
+const loginWithFacebookAndTOTP = Utils2fa.createOAuthTotpLoginMethod(() => Facebook);
+const { loginWithFacebook } = Meteor;
+Meteor.loginWithFacebook = function(options, cb) {
+	Utils2fa.overrideLoginMethod(loginWithFacebook, [options], cb, loginWithFacebookAndTOTP);
+};
+
+const loginWithGithubAndTOTP = Utils2fa.createOAuthTotpLoginMethod(() => Github);
+const { loginWithGithub } = Meteor;
+Meteor.loginWithGithub = function(options, cb) {
+	Utils2fa.overrideLoginMethod(loginWithGithub, [options], cb, loginWithGithubAndTOTP);
+};
+
+const loginWithMeteorDeveloperAccountAndTOTP = Utils2fa.createOAuthTotpLoginMethod(() => MeteorDeveloperAccounts);
+const { loginWithMeteorDeveloperAccount } = Meteor;
+Meteor.loginWithMeteorDeveloperAccount = function(options, cb) {
+	Utils2fa.overrideLoginMethod(loginWithMeteorDeveloperAccount, [options], cb, loginWithMeteorDeveloperAccountAndTOTP);
+};
+
+const loginWithTwitterAndTOTP = Utils2fa.createOAuthTotpLoginMethod(() => Twitter);
+const { loginWithTwitter } = Meteor;
+Meteor.loginWithTwitter = function(options, cb) {
+	Utils2fa.overrideLoginMethod(loginWithTwitter, [options], cb, loginWithTwitterAndTOTP);
+};
+
+const loginWithLinkedinAndTOTP = Utils2fa.createOAuthTotpLoginMethod(() => Linkedin);
+const { loginWithLinkedin } = Meteor;
+Meteor.loginWithLinkedin = function(options, cb) {
+	Utils2fa.overrideLoginMethod(loginWithLinkedin, [options], cb, loginWithLinkedinAndTOTP);
+};
diff --git a/app/2fa/client/TOTPPassword.js b/app/2fa/client/TOTPPassword.js
index cf10bc40581..430049560ba 100644
--- a/app/2fa/client/TOTPPassword.js
+++ b/app/2fa/client/TOTPPassword.js
@@ -2,17 +2,10 @@ import { Meteor } from 'meteor/meteor';
 import { Accounts } from 'meteor/accounts-base';
 import toastr from 'toastr';
 
+import { Utils2fa } from './lib/2fa';
 import { t } from '../../utils';
 import { process2faReturn } from './callWithTwoFactorRequired';
 
-function reportError(error, callback) {
-	if (callback) {
-		callback(error);
-	} else {
-		throw error;
-	}
-}
-
 Meteor.loginWithPasswordAndTOTP = function(selector, password, code, callback) {
 	if (typeof selector === 'string') {
 		if (selector.indexOf('@') === -1) {
@@ -34,7 +27,7 @@ Meteor.loginWithPasswordAndTOTP = function(selector, password, code, callback) {
 		}],
 		userCallback(error) {
 			if (error) {
-				reportError(error, callback);
+				Utils2fa.reportError(error, callback);
 			} else {
 				callback && callback();
 			}
diff --git a/app/2fa/client/TOTPSaml.js b/app/2fa/client/TOTPSaml.js
new file mode 100644
index 00000000000..94c2673b8c5
--- /dev/null
+++ b/app/2fa/client/TOTPSaml.js
@@ -0,0 +1,32 @@
+import { Meteor } from 'meteor/meteor';
+import { Accounts } from 'meteor/accounts-base';
+
+import { Utils2fa } from './lib/2fa';
+import '../../meteor-accounts-saml/client/saml_client';
+
+Meteor.loginWithSamlTokenAndTOTP = function(credentialToken, code, callback) {
+	Accounts.callLoginMethod({
+		methodArguments: [{
+			totp: {
+				login: {
+					saml: true,
+					credentialToken,
+				},
+				code,
+			},
+		}],
+		userCallback(error) {
+			if (error) {
+				Utils2fa.reportError(error, callback);
+			} else {
+				callback && callback();
+			}
+		},
+	});
+};
+
+const { loginWithSamlToken } = Meteor;
+
+Meteor.loginWithSamlToken = function(options, callback) {
+	Utils2fa.overrideLoginMethod(loginWithSamlToken, [options], callback, Meteor.loginWithSamlTokenAndTOTP);
+};
diff --git a/app/2fa/client/index.js b/app/2fa/client/index.js
index 1ad86d365b7..933bd287703 100644
--- a/app/2fa/client/index.js
+++ b/app/2fa/client/index.js
@@ -1,2 +1,6 @@
 import './callWithTwoFactorRequired';
 import './TOTPPassword';
+import './TOTPOAuth';
+import './TOTPGoogle';
+import './TOTPSaml';
+import './TOTPLDAP';
diff --git a/app/2fa/client/lib/2fa.js b/app/2fa/client/lib/2fa.js
new file mode 100644
index 00000000000..b075e4ed8c4
--- /dev/null
+++ b/app/2fa/client/lib/2fa.js
@@ -0,0 +1,89 @@
+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';
+
+export class Utils2fa {
+	static reportError(error, callback) {
+		if (callback) {
+			callback(error);
+		} else {
+			throw error;
+		}
+	}
+
+	static convertError(err) {
+		if (err && err instanceof Meteor.Error && err.error === Accounts.LoginCancelledError.numericError) {
+			return new Accounts.LoginCancelledError(err.reason);
+		}
+
+		return err;
+	}
+
+	static overrideLoginMethod(loginMethod, loginArgs, cb, loginMethodTOTP) {
+		loginMethod.apply(this, loginArgs.concat([(error) => {
+			if (!error || error.error !== 'totp-required') {
+				return cb(error);
+			}
+
+			process2faReturn({
+				error,
+				originalCallback: cb,
+				onCode: (code) => {
+					loginMethodTOTP && loginMethodTOTP.apply(this, loginArgs.concat([code, (error) => {
+						console.log('failed');
+						console.log(error);
+						if (error && error.error === 'totp-invalid') {
+							toastr.error(t('Invalid_two_factor_code'));
+							cb();
+						} else {
+							cb(error);
+						}
+					}]));
+				},
+			});
+		}]));
+	}
+
+	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 7138a7b3d61..49a9b7f56ce 100644
--- a/app/2fa/server/loginHandler.js
+++ b/app/2fa/server/loginHandler.js
@@ -12,11 +12,13 @@ Accounts.registerLoginHandler('totp', function(options) {
 });
 
 callbacks.add('onValidateLogin', (login) => {
-	if (login.type !== 'password') {
-		return;
+	if (login.type === 'resume' || login.type === 'proxy') {
+		return login;
 	}
 
 	const { totp } = login.methodArguments[0];
 
 	checkCodeForUser({ user: login.user, code: totp && totp.code, options: { disablePasswordFallback: true } });
+
+	return login;
 }, callbacks.priority.MEDIUM, '2fa');
diff --git a/app/api/server/api.js b/app/api/server/api.js
index d717396a498..4fee7cd8f5e 100644
--- a/app/api/server/api.js
+++ b/app/api/server/api.js
@@ -432,6 +432,7 @@ export class APIClass extends Restivus {
 		const loginCompatibility = (bodyParams, request) => {
 			// Grab the username or email that the user is logging in with
 			const { user, username, email, password, code: bodyCode } = bodyParams;
+			let usernameToLDAPLogin = '';
 
 			if (password == null) {
 				return bodyParams;
@@ -449,10 +450,13 @@ export class APIClass extends Restivus {
 
 			if (typeof user === 'string') {
 				auth.user = user.includes('@') ? { email: user } : { username: user };
+				usernameToLDAPLogin = user;
 			} else if (username) {
 				auth.user = { username };
+				usernameToLDAPLogin = username;
 			} else if (email) {
 				auth.user = { email };
+				usernameToLDAPLogin = email;
 			}
 
 			if (auth.user == null) {
@@ -466,11 +470,21 @@ export class APIClass extends Restivus {
 				};
 			}
 
+			const objectToLDAPLogin = {
+				ldap: true,
+				username: usernameToLDAPLogin,
+				ldapPass: auth.password,
+				ldapOptions: {},
+			};
+			if (settings.get('LDAP_Enable') && !code) {
+				return objectToLDAPLogin;
+			}
+
 			if (code) {
 				return {
 					totp: {
 						code,
-						login: auth,
+						login: settings.get('LDAP_Enable') ? objectToLDAPLogin : auth,
 					},
 				};
 			}
diff --git a/app/meteor-accounts-saml/client/saml_client.js b/app/meteor-accounts-saml/client/saml_client.js
index 0c30b46093c..3533ff2a4e8 100644
--- a/app/meteor-accounts-saml/client/saml_client.js
+++ b/app/meteor-accounts-saml/client/saml_client.js
@@ -67,3 +67,13 @@ Meteor.logoutWithSaml = function(options/* , callback*/) {
 		window.location.replace(Meteor.absoluteUrl(`_saml/sloRedirect/${ options.provider }/?redirect=${ encodeURIComponent(result) }`));
 	});
 };
+
+Meteor.loginWithSamlToken = function(token, userCallback) {
+	Accounts.callLoginMethod({
+		methodArguments: [{
+			saml: true,
+			credentialToken: token,
+		}],
+		userCallback,
+	});
+};
diff --git a/client/routes.js b/client/routes.js
index 00de91749a0..a0f0fb2938e 100644
--- a/client/routes.js
+++ b/client/routes.js
@@ -1,6 +1,5 @@
 import mem from 'mem';
 import { Meteor } from 'meteor/meteor';
-import { Accounts } from 'meteor/accounts-base';
 import { Tracker } from 'meteor/tracker';
 import { FlowRouter } from 'meteor/kadira:flow-router';
 import { BlazeLayout } from 'meteor/kadira:blaze-layout';
@@ -71,25 +70,26 @@ FlowRouter.route('/home', {
 	action(params, queryParams) {
 		KonchatNotification.getDesktopPermission();
 		if (queryParams.saml_idp_credentialToken !== undefined) {
-			Accounts.callLoginMethod({
-				methodArguments: [{
-					saml: true,
-					credentialToken: queryParams.saml_idp_credentialToken,
-				}],
-				userCallback(error) {
-					if (error) {
-						if (error.reason) {
-							toastr.error(error.reason);
-						} else {
-							handleError(error);
-						}
+			const token = queryParams.saml_idp_credentialToken;
+			FlowRouter.setQueryParams({
+				saml_idp_credentialToken: null,
+			});
+			Meteor.loginWithSamlToken(token, (error) => {
+				if (error) {
+					if (error.reason) {
+						toastr.error(error.reason);
+					} else {
+						handleError(error);
 					}
-					BlazeLayout.render('main', { center: 'home' });
-				},
+				}
+
+				BlazeLayout.render('main', { center: 'home' });
 			});
-		} else {
-			BlazeLayout.render('main', { center: 'home' });
+
+			return;
 		}
+
+		BlazeLayout.render('main', { center: 'home' });
 	},
 });
 
diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json
index 08ab7f3045d..c55b373ae60 100644
--- a/packages/rocketchat-i18n/i18n/en.i18n.json
+++ b/packages/rocketchat-i18n/i18n/en.i18n.json
@@ -3626,6 +3626,8 @@
   "Total_visitors": "Total Visitors",
   "totp-invalid": "Code or password invalid",
   "TOTP Invalid [totp-invalid]": "Code or password invalid",
+  "totp-disabled": "You do not have 2FA login enabled for your user",
+  "totp-required": "TOTP Required",
   "Tourism": "Tourism",
   "Transcript": "Transcript",
   "Transcript_Enabled": "Ask Visitor if They Would Like a Transcript After Chat Closed",
-- 
GitLab