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