[NEW] 2 Factor Authentication when using OAuth and SAML (#11726)

pull/19642/head
Pierre H. Lehnen 5 years ago committed by GitHub
parent 5c8a15258d
commit 378ef37349
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 41
      app/2fa/client/TOTPGoogle.js
  2. 50
      app/2fa/client/TOTPLDAP.js
  3. 72
      app/2fa/client/TOTPOAuth.js
  4. 11
      app/2fa/client/TOTPPassword.js
  5. 32
      app/2fa/client/TOTPSaml.js
  6. 4
      app/2fa/client/index.js
  7. 89
      app/2fa/client/lib/2fa.js
  8. 6
      app/2fa/server/loginHandler.js
  9. 16
      app/api/server/api.js
  10. 10
      app/meteor-accounts-saml/client/saml_client.js
  11. 34
      client/routes.js
  12. 2
      packages/rocketchat-i18n/i18n/en.i18n.json

@ -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);
};

@ -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);
};

@ -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);
};

@ -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();
}

@ -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);
};

@ -1,2 +1,6 @@
import './callWithTwoFactorRequired';
import './TOTPPassword';
import './TOTPOAuth';
import './TOTPGoogle';
import './TOTPSaml';
import './TOTPLDAP';

@ -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);
};
};

@ -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');

@ -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,
},
};
}

@ -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,
});
};

@ -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' });
},
});

@ -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",

Loading…
Cancel
Save