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