From 982c15ae0e2ba481159082c4204e80fcef049e8f Mon Sep 17 00:00:00 2001 From: yflory Date: Thu, 9 Nov 2023 15:35:56 +0100 Subject: [PATCH 1/3] Add an option to enforce MFA for all accounts on the instance --- config/config.example.js | 8 +++ customize.dist/src/less2/include/loading.less | 3 +- .../src/less2/include/modals-ui-elements.less | 38 +++++++++++++ lib/env.js | 1 + lib/http-worker.js | 3 +- www/common/common-ui-elements.js | 16 ++++++ www/common/cryptpad-common.js | 17 ++++++ www/common/sframe-common-outer.js | 57 +++++++++++++++++++ www/common/sframe-common.js | 7 +++ www/settings/inner.js | 11 +++- www/settings/main.js | 40 ------------- 11 files changed, 158 insertions(+), 43 deletions(-) diff --git a/config/config.example.js b/config/config.example.js index f95da7a65..69e771bfd 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -127,6 +127,14 @@ module.exports = { */ //otpSessionExpiration: 7*24, // hours + /* Registered users can be forced to protect their account + * with a Multi-factor Authentication (MFA) tool like a TOTP + * authenticator application. + * + * defaults to false + */ + //enforceMFA: false, + /* ===================== * Admin * ===================== */ diff --git a/customize.dist/src/less2/include/loading.less b/customize.dist/src/less2/include/loading.less index c139ae667..6bd87718a 100644 --- a/customize.dist/src/less2/include/loading.less +++ b/customize.dist/src/less2/include/loading.less @@ -57,7 +57,7 @@ .cp-loading-container { width: 700px; max-width: 90vw; - height: 236px; + min-height: 236px; max-height: calc(100vh - 20px); margin: 50px; flex-shrink: 0; @@ -100,6 +100,7 @@ color: @cp_loading-fg; text-align: left; display: none; + overflow-y: auto; a { color: @cp_loading-link; } diff --git a/customize.dist/src/less2/include/modals-ui-elements.less b/customize.dist/src/less2/include/modals-ui-elements.less index 6bc1b9245..39a763626 100644 --- a/customize.dist/src/less2/include/modals-ui-elements.less +++ b/customize.dist/src/less2/include/modals-ui-elements.less @@ -35,6 +35,44 @@ } } + .cp-loading-missing-mfa { + .cp-settings-qr-container { + display: flex; + align-items: center; + justify-content: space-evenly; + .cp-settings-qr-code { + input { + max-width: 250px; + } + button { + margin-top: 10px; + } + } + } + .cp-settings-qr { + img { + border: 10px solid white; + border-radius: 10px; + } + margin: 10px 10px 10px 0; + } + .cp-password-container { + flex-wrap: wrap; + gap:0.5rem; + + justify-content:flex-start; + input { + flex-shrink: 1; + max-width: 400px; + } + label { + width: 100%; + font-weight: unset; + margin-bottom: 5px; + } + } + } + // Properties modal .cp-app-prop { margin-bottom: 10px; diff --git a/lib/env.js b/lib/env.js index 82870626e..9907500e7 100644 --- a/lib/env.js +++ b/lib/env.js @@ -224,6 +224,7 @@ module.exports.create = function (config) { commandTimers: {}, sso: config.sso, + enforceMFA: config.enforceMFA, // initialized as undefined bearerSecret: void 0, diff --git a/lib/http-worker.js b/lib/http-worker.js index 94a68ae76..0bbe4593d 100644 --- a/lib/http-worker.js +++ b/lib/http-worker.js @@ -577,7 +577,8 @@ var serveConfig = makeRouteCache(function () { shouldUpdateNode: Env.shouldUpdateNode || undefined, listMyInstance: Env.listMyInstance, accounts_api: Env.accounts_api, - sso: ssoCfg + sso: ssoCfg, + enforceMFA: Env.enforceMFA }, null, '\t'), '});' ].join(';\n'); diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 3a03807f2..7fc4002e1 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -4040,5 +4040,21 @@ define([ modal = UI.openCustomModal(UI.dialog.customModal(content, {buttons: buttons })); }; + Messages.loading_mfa_required = "Multi-factor Authentication is required on this instance. Please update your account using an anthenticator app and the form below."; // XXX + UIElements.onMissingMFA = (common, config, cb) => { + let content = h('div'); + let msg = h('div.cp-loading-missing-mfa', [ + h('div.alert.alert-warning', Messages.loading_mfa_required), + content + ]); + common.totpSetup(config, content, false, (newState) => { + if (!newState) { + return void UI.errorLoadingScreen(Messages.error); + } + cb({state: true}); + }); + return UI.errorLoadingScreen(msg, false, false); + }; + return UIElements; }); diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index f0a4d3801..7a27350da 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -2160,6 +2160,7 @@ define([ // Loading events common.loading = {}; common.loading.onDriveEvent = Util.mkEvent(); + common.loading.onMissingMFAEvent = Util.mkEvent(); // (Auto)store pads common.autoStore = {}; @@ -2484,6 +2485,22 @@ define([ if (AppConfig.beforeLogin) { AppConfig.beforeLogin(LocalStore.isLoggedIn(), waitFor()); } + }).nThen(function (waitFor) { + var blockHash = LocalStore.getBlockHash(); + if (!blockHash || !Config.enforceMFA) { return; } + + // If this instance is configured to enforce MFA for all registered users, + // request the login block with no credential to check if it is protected. + var parsed = Block.parseBlockHash(blockHash); + Util.getBlock(parsed.href, { }, waitFor((err, response) => { + // If this account is already protected, nothing to do + if (err === 401 && response.method) { return; } + + // Missing MFA protection, show set up screen + common.loading.onMissingMFAEvent.fire({ + cb: waitFor() + }); + })); }).nThen(function (waitFor) { // if a block URL is present then the user is probably logged in with a modern account diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index 4ccda4b68..a8d1e401a 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -226,6 +226,61 @@ define([ } } }; + + var addFirstHandlers = () => { + sframeChan.on('Q_SETTINGS_CHECK_PASSWORD', function (data, cb) { + var blockHash = Utils.LocalStore.getBlockHash(); + var userHash = Utils.LocalStore.getUserHash(); + var correct = (blockHash && blockHash === data.blockHash) || + (!blockHash && userHash === data.userHash); + cb({correct: correct}); + }); + sframeChan.on('Q_SETTINGS_TOTP_SETUP', function (obj, cb) { + require([ + '/common/outer/http-command.js', + ], function (ServerCommand) { + var data = obj.data; + data.command = 'TOTP_SETUP'; + data.session = Utils.LocalStore.getSessionToken(); + ServerCommand(obj.key, data, function (err, response) { + cb({ success: Boolean(!err && response && response.bearer) }); + if (response && response.bearer) { + Utils.LocalStore.setSessionToken(response.bearer); + } + }); + }); + }); + sframeChan.on('Q_SETTINGS_TOTP_REVOKE', function (obj, cb) { + require([ + '/common/outer/http-command.js', + ], function (ServerCommand) { + ServerCommand(obj.key, obj.data, function (err, response) { + cb({ success: Boolean(!err && response && response.success) }); + if (response && response.success) { + Utils.LocalStore.setSessionToken(''); + } + }); + }); + }); + sframeChan.on('Q_SETTINGS_GET_SSO_SEED', function (obj, _cb) { + var cb = Utils.Util.mkAsync(_cb); + cb({ + seed: Utils.LocalStore.getSSOSeed() + }); + }); + Cryptpad.loading.onMissingMFAEvent.reg((data) => { + var cb = data.cb; + if (!sframeChan) { return void cb('EINVAL'); } + sframeChan.query('Q_LOADING_MISSING_AUTH', { + accountName: Utils.LocalStore.getAccountName(), + origin: window.location.origin, + }, (err, obj) => { + if (obj && obj.state) { return void cb(true); } + console.error(err || obj); + }); + }); + }; + var whenReady = waitFor(function (msg) { if (msg.source !== iframe) { return; } var data = typeof(msg.data) === "string" ? JSON.parse(msg.data) : msg.data; @@ -242,6 +297,7 @@ define([ }); SFrameChannel.create(msgEv, postMsg, waitFor(function (sfc) { Utils.sframeChan = sframeChan = sfc; + addFirstHandlers(); window.CryptPad_loadingError = function (e) { sfc.event('EV_LOADING_ERROR', e); }; @@ -267,6 +323,7 @@ define([ } } catch (e) { console.error(e); } + // NOTE: Driveless mode should only work for existing pads, but we can't check that // before creating the worker because we need the anon RPC to do so. // We're only going to check if a hash exists in the URL or not. diff --git a/www/common/sframe-common.js b/www/common/sframe-common.js index 8f56cb242..b63096bf0 100644 --- a/www/common/sframe-common.js +++ b/www/common/sframe-common.js @@ -15,6 +15,7 @@ define([ '/common/sframe-common-mailbox.js', '/common/inner/cache.js', '/common/inner/common-mediatag.js', + '/common/inner/mfa.js', '/common/metadata-manager.js', '/customize/application_config.js', @@ -46,6 +47,7 @@ define([ Mailbox, Cache, MT, + MFA, MetadataMgr, AppConfig, Pages, @@ -119,6 +121,7 @@ define([ funcs.importMediaTagMenu = callWithCommon(MT.importMediaTagMenu); funcs.getMediaTagPreview = callWithCommon(MT.getMediaTagPreview); funcs.getMediaTag = callWithCommon(MT.getMediaTag); + funcs.totpSetup = callWithCommon(MFA.totpSetup); // Thumb funcs.displayThumbnail = callWithCommon(Thumb.displayThumbnail); @@ -889,6 +892,10 @@ define([ UI.updateLoadingProgress(data); }); + ctx.sframeChan.on('Q_LOADING_MISSING_AUTH', function (data, cb) { + UIElements.onMissingMFA(funcs, data, cb); + }); + ctx.sframeChan.on('EV_NEW_VERSION', function () { // TODO lock the UI and do the same in non-framework apps var $err = $('
').append(Messages.newVersionError); diff --git a/www/settings/inner.js b/www/settings/inner.js index 041036739..26a66ebf8 100644 --- a/www/settings/inner.js +++ b/www/settings/inner.js @@ -1236,7 +1236,16 @@ define([ sframeChan.query('Q_SETTINGS_MFA_CHECK', {}, function (err, obj) { if (err || !obj || (obj && obj.err === 'NOBLOCK')) { return void cb(false); } var enabled = obj && obj.mfa && obj.type === 'TOTP'; - drawMfa(content, Boolean(enabled)); + var config = { + accountName: privateData.accountName, + origin: privateData.origin + }; + var draw = (state) => { + common.totpSetup(config, content, state, (newState) => { + draw(newState); + }); + }; + draw(Boolean(enabled)); cb(content); }); }, true); diff --git a/www/settings/main.js b/www/settings/main.js index 8f6a6f605..21a488803 100644 --- a/www/settings/main.js +++ b/www/settings/main.js @@ -70,40 +70,6 @@ define([ sframeChan.on('Q_SETTINGS_IMPORT_LOCAL', function (data, cb) { Cryptpad.mergeAnonDrive(cb); }); - sframeChan.on('Q_SETTINGS_CHECK_PASSWORD', function (data, cb) { - var blockHash = Utils.LocalStore.getBlockHash(); - var userHash = Utils.LocalStore.getUserHash(); - var correct = (blockHash && blockHash === data.blockHash) || - (!blockHash && userHash === data.userHash); - cb({correct: correct}); - }); - sframeChan.on('Q_SETTINGS_TOTP_SETUP', function (obj, cb) { - require([ - '/common/outer/http-command.js', - ], function (ServerCommand) { - var data = obj.data; - data.command = 'TOTP_SETUP'; - data.session = Utils.LocalStore.getSessionToken(); - ServerCommand(obj.key, data, function (err, response) { - cb({ success: Boolean(!err && response && response.bearer) }); - if (response && response.bearer) { - Utils.LocalStore.setSessionToken(response.bearer); - } - }); - }); - }); - sframeChan.on('Q_SETTINGS_TOTP_REVOKE', function (obj, cb) { - require([ - '/common/outer/http-command.js', - ], function (ServerCommand) { - ServerCommand(obj.key, obj.data, function (err, response) { - cb({ success: Boolean(!err && response && response.success) }); - if (response && response.success) { - Utils.LocalStore.setSessionToken(''); - } - }); - }); - }); sframeChan.on('Q_SETTINGS_MFA_CHECK', function (obj, cb) { require([ '/common/outer/login-block.js', @@ -120,12 +86,6 @@ define([ }); }); }); - sframeChan.on('Q_SETTINGS_GET_SSO_SEED', function (obj, _cb) { - var cb = Utils.Util.mkAsync(_cb); - cb({ - seed: Utils.LocalStore.getSSOSeed() - }); - }); sframeChan.on('Q_SETTINGS_REMOVE_OWNED_PADS', function (data, cb) { Cryptpad.removeOwnedPads(data, cb); }); From bc6ab53193907f4314860af2d7a95ad1784bb3eb Mon Sep 17 00:00:00 2001 From: yflory Date: Thu, 9 Nov 2023 17:26:18 +0100 Subject: [PATCH 2/3] Add missing file --- www/common/inner/mfa.js | 321 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 www/common/inner/mfa.js diff --git a/www/common/inner/mfa.js b/www/common/inner/mfa.js new file mode 100644 index 000000000..0f4fdbba5 --- /dev/null +++ b/www/common/inner/mfa.js @@ -0,0 +1,321 @@ +define([ + 'jquery', + '/customize/messages.js', + '/common/hyperscript.js', + '/common/common-interface.js', + '/components/nthen/index.js', + '/customize.dist/login.js', + '/common/common-util.js', + +], function ($, Messages, h, UI, nThen, Login, Util) { + const MFA = {}; + + MFA.totpSetup = function (common, config, content, enabled, cb) { + + var sframeChan = common.getSframeChannel(); + // NOTE privateData may not be defined yet + var accountName = config.accountName; + var origin = config.origin; + + var $content = $(content).empty(); + $content.append(h('div.cp-settings-mfa-hint.cp-settings-mfa-status' + (enabled ? '.mfa-enabled' : '.mfa-disabled'), [ + h('i.fa' + (enabled ? '.fa-check' : '.fa-times')), + h('span', enabled ? Messages.mfa_status_on : Messages.mfa_status_off) + ])); + + if (enabled) { + (function () { + var button = h('button.btn', Messages.mfa_disable); + button.classList.add('disable-button'); + var $mfaRevokeBtn = $(button); + var pwInput; + var pwContainer = h('div.cp-password-container', [ + h('label.cp-settings-mfa-hint', { for: 'cp-mfa-password' }, Messages.mfa_revoke_label), + pwInput = h('input#cp-mfa-password', { + type: 'password', + placeholder: Messages.login_password, + }), + button + ]); + $content.append(pwContainer); + + // submit password on enter keyup + $(pwInput).on('keyup', e => { + if (e.which === 13) { $mfaRevokeBtn.click(); } + }); + + var spinner = UI.makeSpinner($mfaRevokeBtn); + $mfaRevokeBtn.click(function () { + var name = accountName; + var password = $(pwInput).val(); + if (!password) { return void UI.warn(Messages.login_noSuchUser); } + + spinner.spin(); + $(pwInput).prop('disabled', 'disabled'); + $mfaRevokeBtn.prop('disabled', 'disabled'); + var blockKeys; + + nThen(function (waitFor) { + var next = waitFor(); + // scrypt locks up the UI before the DOM has a chance + // to update (displaying logs, etc.), so do a set timeout + setTimeout(function () { + Login.Cred.deriveFromPassphrase(name, password, Login.requiredBytes, function (bytes) { + var result = Login.allocateBytes(bytes); + sframeChan.query("Q_SETTINGS_CHECK_PASSWORD", { + blockHash: result.blockHash, + }, function (err, obj) { + if (!obj || !obj.correct) { + spinner.hide(); + UI.warn(Messages.login_noSuchUser); + $mfaRevokeBtn.removeAttr('disabled'); + $(pwInput).removeAttr('disabled'); + waitFor.abort(); + return; + } + spinner.done(); + blockKeys = result.blockKeys; + next(); + }); + }); + }, 100); + }).nThen(function () { + $(pwContainer).remove(); + var OTPEntry; + var disable = h('button.btn.disable-button', Messages.mfa_revoke_button); + $content.append(h('div.cp-password-container', [ + h('label.cp-settings-mfa-hint', { for: 'cp-mfa-password' }, Messages.mfa_revoke_code), + OTPEntry = h('input', { + placeholder: Messages.settings_otp_code + }), + disable + ])); + var $OTPEntry = $(OTPEntry); + var $d = $(disable).click(function () { + $d.prop('disabled', 'disabled'); + var code = $OTPEntry.val(); + sframeChan.query("Q_SETTINGS_TOTP_REVOKE", { + key: blockKeys.sign, + data: { + command: 'TOTP_REVOKE', + code: code, + } + }, function (err, obj) { + $OTPEntry.val(""); + if (err || !obj || !obj.success) { + $d.removeAttr('disabled'); + return void UI.warn(Messages.settings_otp_invalid); + } + cb(false); + }, {raw: true}); + + }); + OTPEntry.focus(); + // submit OTP on enter keyup + $OTPEntry.on('keyup', e => { + if (e.which === 13) { $d.click(); } + }); + }); + }); + + })(); + return; + } + + var button = h('button.btn.btn-primary', Messages.mfa_setup_button); + var $mfaSetupBtn = $(button); + var pwInput; + $content.append(h('div.cp-password-container', [ + h('label.cp-settings-mfa-hint', { for: 'cp-mfa-password' }, Messages.mfa_setup_label), + pwInput = h('input#cp-mfa-password', { + type: 'password', + placeholder: Messages.login_password, + }), + button + ])); + var spinner = UI.makeSpinner($mfaSetupBtn); + + // submit password on enter keyup + $(pwInput).on('keyup', e => { + if (e.which === 13) { $(button).click(); } + }); + + $(button).click(function () { + var name = accountName; + var password = $(pwInput).val(); + if (!password) { return void UI.warn(Messages.login_noSuchUser); } + + spinner.spin(); + $(pwInput).prop('disabled', 'disabled'); + $mfaSetupBtn.prop('disabled', 'disabled'); + + var Base32, QRCode, Nacl; + var blockKeys; + var recoverySecret; + var ssoSeed; + nThen(function (waitFor) { + require([ + '/auth/base32.js', + '/lib/qrcode.min.js', + '/components/tweetnacl/nacl-fast.min.js', + ], waitFor(function (_Base32) { + Base32 = _Base32; + QRCode = window.QRCode; + Nacl = window.nacl; + })); + }).nThen(function (waitFor) { + sframeChan.query("Q_SETTINGS_GET_SSO_SEED", { + }, waitFor(function (err, obj) { + if (!obj || !obj.seed) { return; } // Not an sso account? + ssoSeed = obj.seed; + })); + }).nThen(function (waitFor) { + var next = waitFor(); + // scrypt locks up the UI before the DOM has a chance + // to update (displaying logs, etc.), so do a set timeout + setTimeout(function () { + var salt = ssoSeed || name; + Login.Cred.deriveFromPassphrase(salt, password, Login.requiredBytes, function (bytes) { + console.error(bytes); + var result = Login.allocateBytes(bytes); + sframeChan.query("Q_SETTINGS_CHECK_PASSWORD", { + blockHash: result.blockHash, + }, function (err, obj) { + console.error(obj); + if (!obj || !obj.correct) { + spinner.hide(); + UI.warn(Messages.login_noSuchUser); + $mfaSetupBtn.removeAttr('disabled'); + $(pwInput).removeAttr('disabled'); + waitFor.abort(); + return; + } + console.warn(obj); + spinner.done(); + blockKeys = result.blockKeys; + next(); + }); + }); + }, 100); + }).nThen(function (waitFor) { + $content.empty(); + var next = waitFor(); + recoverySecret = Nacl.util.encodeBase64(Nacl.randomBytes(24)); + var button = h('button.btn.btn-primary', [ + h('i.fa.fa-check'), + h('span', Messages.done) + ]); + $content.append(h('div.alert.alert-danger', [ + h('h2', Messages.mfa_recovery_title), + h('p', Messages.mfa_recovery_hint), + h('p', Messages.mfa_recovery_warning), + h('div.cp-password-container', [ + UI.dialog.selectable(recoverySecret), + button + ]) + ])); + + var nextButton = h('button.btn.btn-primary', { + 'disabled': 'disabled' + }, Messages.continue); + $(nextButton).click(function () { + next(); + }).appendTo($content); + + $(button).click(function () { + $content.find('.alert-danger').removeClass('alert-danger').addClass('alert-success'); + $(button).prop('disabled', 'disabled'); + $(nextButton).removeAttr('disabled'); + }); + }).nThen(function () { + var randomSecret = function () { + var U8 = Nacl.randomBytes(20); + return Base32.encode(U8); + }; + $content.empty(); + + var updateQR = Util.mkAsync(function (uri, target) { + new QRCode(target, uri); + }); + var updateURI = function (secret) { + var username = accountName; + var hostname = new URL(origin).hostname; + var label = "CryptPad"; + + var uri = `otpauth://totp/${label}:${username}@${hostname}?secret=${secret}`; + + var qr = h('div.cp-settings-qr'); + var uriInput = UI.dialog.selectable(uri); + + updateQR(uri, qr); + + var OTPEntry = h('input', { + placeholder: Messages.settings_otp_code + }); + var $OTPEntry = $(OTPEntry); + + var description = h('p.cp-settings-mfa-hint', Messages.settings_otp_tuto); + var confirmOTP = h('button.btn.btn-primary', [ + h('i.fa.fa-check'), + h('span', Messages.mfa_enable) + ]); + var lock = false; + + confirmOTP.addEventListener('click', function () { + var code = $OTPEntry.val(); + if (code.length !== 6 || /\D/.test(code)) { + return void UI.warn(Messages.settings_otp_invalid); + } + confirmOTP.disabled = true; + lock = true; + + var data = { + secret: secret, + contact: "secret:" + recoverySecret, // TODO other recovery options + code: code, + }; + + sframeChan.query("Q_SETTINGS_TOTP_SETUP", { + key: blockKeys.sign, + data: data + }, function (err, obj) { + lock = false; + $OTPEntry.val(""); + if (err || !obj || !obj.success) { + confirmOTP.disabled = false; + console.error(err); + return void UI.warn(Messages.error); + } + cb(true); + }, { raw: true }); + }); + + $content.append([ + description, + uriInput, + h('div.cp-settings-qr-container', [ + qr, + h('div.cp-settings-qr-code', [ + OTPEntry, + h('br'), + confirmOTP + ]) + ]) + ]); + OTPEntry.focus(); + // submit OTP on enter keyup + $OTPEntry.on('keyup', e => { + if (e.which === 13) { $(confirmOTP).click(); } + }); + }; + + + var secret = randomSecret(); + updateURI(secret); + }); + + }); + }; + + return MFA; +}); From ac090767ca1a7db48ade899b9d2befbb3ad0b43d Mon Sep 17 00:00:00 2001 From: yflory Date: Mon, 11 Dec 2023 16:40:05 +0100 Subject: [PATCH 3/3] Add admin panel option to enforce MFA --- lib/commands/admin-rpc.js | 1 + lib/decrees.js | 3 +++ www/admin/inner.js | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/lib/commands/admin-rpc.js b/lib/commands/admin-rpc.js index 07a121909..3100194b4 100644 --- a/lib/commands/admin-rpc.js +++ b/lib/commands/admin-rpc.js @@ -495,6 +495,7 @@ var instanceStatus = function (Env, Server, cb) { instanceJurisdiction: Env.instanceJurisdiction, instanceName: Env.instanceName, instanceNotice: Env.instanceNotice, + enforceMFA: Env.enforceMFA, }); }; diff --git a/lib/decrees.js b/lib/decrees.js index 2e2ffd43a..1f81db056 100644 --- a/lib/decrees.js +++ b/lib/decrees.js @@ -107,6 +107,9 @@ var makeBooleanSetter = function (attr) { // CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['DISABLE_EMBEDDING', [true]]], console.log) commands.ENABLE_EMBEDDING = makeBooleanSetter('enableEmbedding'); +// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['ENFORCE_MFA', [true]]], console.log) +commands.ENFORCE_MFA = makeBooleanSetter('enforceMFA'); + // CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['RESTRICT_REGISTRATION', [true]]], console.log) commands.RESTRICT_REGISTRATION = makeBooleanSetter('restrictRegistration'); diff --git a/www/admin/inner.js b/www/admin/inner.js index 8155a1164..798c1ddcd 100644 --- a/www/admin/inner.js +++ b/www/admin/inner.js @@ -63,6 +63,7 @@ define([ 'cp-admin-update-limit', 'cp-admin-registration', 'cp-admin-enableembeds', + 'cp-admin-forcemfa', 'cp-admin-email', 'cp-admin-instance-info-notice', @@ -1527,6 +1528,31 @@ Example }, }); + // Msg.admin_forcemfaHint, .admin_forcemfaTitle + Messages.admin_forcemfaTitle = "Enforce MFA on this instance"; // XXX + Messages.admin_forcemfaHint = "All CryptPad users will be asked to set up a multi-factor authenticator (TOTP) to log in to their account."; // XXX + create['forcemfa'] = makeAdminCheckbox({ + key: 'forcemfa', + getState: function () { + return APP.instanceStatus.enforceMFA; + }, + query: function (val, setState) { + sFrameChan.query('Q_ADMIN_RPC', { + cmd: 'ADMIN_DECREE', + data: ['ENFORCE_MFA', [val]] + }, function (e, response) { + if (e || response.error) { + UI.warn(Messages.error); + console.error(e, response); + } + APP.updateStatus(function () { + setState(APP.instanceStatus.enforceMFA); + flushCacheNotice(); + }); + }); + }, + }); + create['email'] = function () { var key = 'email'; var $div = makeBlock(key, true); // Msg.admin_emailHint, Msg.admin_emailTitle