diff --git a/customize.dist/pages/recovery.js b/customize.dist/pages/recovery.js new file mode 100644 index 000000000..c5736f4ac --- /dev/null +++ b/customize.dist/pages/recovery.js @@ -0,0 +1,128 @@ +define([ + '/api/config', + 'jquery', + '/common/hyperscript.js', + '/common/common-interface.js', + '/customize/messages.js', + '/customize/pages.js' +], function (Config, $, h, UI, Msg, Pages) { + return function () { + document.title = Msg.register_header; + + var tos = $(UI.createCheckbox('accept-terms')).find('.cp-checkmark-label').append(Msg.register_acceptTerms).parent()[0]; + + var termsLink = Pages.customURLs.terms; + $(tos).find('a').attr({ + href: termsLink, + target: '_blank', + tabindex: '-1', + }); + +Msg.recovery_header = "Recover account"; // XXX +Msg.recovery_totp = "Disable TOTP on your account"; // XXX +Msg.recovery_totp_description = "If you've locked yourselves out of your CryptPad account because of a TOTP (Time based One Time Password) multi-factor authentication, you can use this form to disable this protection."; +Msg.recovery_totp_beta = 'The TOTP multi-factor authentication has just been released. To avoid accidentaly locking accounts, the support team will temporarily agree to disable the protection even if you forgot your secret recovery key. If you forgot your recovery key, please copy the proof of ownership and send it to {0}'; +Msg.recovery_totp_login = "Please enter your login credentials"; +Msg.recovery_totp_secret = "Please enter your secret recovery key"; +Msg.recovery_totp_secret_ph = "Secret recovery key"; +Msg.recovery_totp_proof = "Proof of ownership"; +Msg.recovery_totp_continue = "Continue"; +Msg.recovery_totp_disable = "Disable TOTP"; + +Msg.recovery_totp_method_email = "Manual recovery by email"; +Msg.recovery_totp_method_secret = "Automatic recovery by secret key"; + +Msg.recovery_totp_wrong = "Invalid username or password"; +Msg.recovery_totp_error = "Unknown error. Please reload and try again."; +Msg.recovery_totp_disabled = "Multi-factor authentication is already disabled for this account."; + + var frame = function (content) { + return [ + h('div#cp-main', [ + Pages.infopageTopbar(), + h('div.container.cp-container', [ + h('div.row.cp-page-title', h('h1', Msg.recovery_header)), + ].concat(content)), + Pages.infopageFooter(), + ]), + ]; + }; + + if (Config.restrictRegistration) { + return frame([ + h('div.cp-restricted-registration', [ + h('p', Msg.register_registrationIsClosed), + ]) + ]); + } + + var termsCheck; + if (termsLink) { + termsCheck = h('div.checkbox-container', tos); + } + + return frame([ + h('div.row.cp-recovery-det', [ + h('div#userForm.form-group.hidden.col-md-12', [ + h('h2', Msg.recovery_totp), + h('div.cp-recovery-desc', [ + Msg._getKey('recovery_totp_description', [ Pages.Instance.name ]), + ]), + h('div.cp-recovery-step.step1', [ + h('div.alert.alert-danger.wrong-cred.cp-hidden', Msg.recovery_totp_wrong), + h('label', Msg.recovery_totp_login), + h('input.form-control#username', { + type: 'text', + autocomplete: 'off', + autocorrect: 'off', + autocapitalize: 'off', + spellcheck: false, + placeholder: Msg.login_username, + autofocus: true, + }), + h('input.form-control#password', { + type: 'password', + placeholder: Msg.login_password, + }), + h('div.cp-recover-button', + h('button.btn.btn-primary#cp-recover-login', Msg.recovery_totp_continue) + ) + ]), + h('div.cp-recovery-step.step2', { style: 'display: none;' }, [ + h('div.cp-recovery-method', [ + h('h3', Msg.recovery_totp_method_secret), + h('label', Msg.recovery_totp_secret), + h('input.form-control#totprecovery', { + type: 'text', + autocomplete: 'off', + autocorrect: 'off', + autocapitalize: 'off', + spellcheck: false, + placeholder: Msg.recovery_totp_secret_ph, + autofocus: true, + }), + h('div.cp-recover-button', + h('button.btn.btn-primary#cp-recover', Msg.recovery_totp_disable) + ) + ]), + h('div.cp-recovery-desc', Msg.settings_kanbanTagsOr), + h('div.cp-recovery-method', [ + h('h3', Msg.recovery_totp_method_email), + Config.adminEmail ? UI.setHTML(h('div.alert.alert-warning'), + Msg._getKey('recovery_totp_beta', [Config.adminEmail])) : undefined, + h('label', Msg.recovery_totp_proof), + h('textarea.cp-recover-email', {readonly: 'readonly'}), + h('button.btn.btn-secondary', Msg.copyToClipboard), + ]), + ]), + h('div.cp-recovery-step.step-info', { style: 'display: none;' }, [ + h('div.alert.alert-info.cp-hidden.disabled', Msg.recovery_totp_disabled), + h('div.alert.alert-danger.cp-hidden.unknown-error', Msg.recovery_totp_error), + ]), + ]) + ]) + ]); + }; + +}); + diff --git a/customize.dist/src/less2/include/infopages.less b/customize.dist/src/less2/include/infopages.less index 1882ffd6d..d65e62868 100644 --- a/customize.dist/src/less2/include/infopages.less +++ b/customize.dist/src/less2/include/infopages.less @@ -20,6 +20,11 @@ .infopages_main () { --LessLoader_require: LessLoader_currentFile(); } + +.cp-loading-noscroll { + overflow: hidden; +} + body.html { .font_main(); @infopages_infobar-height: 64px; @@ -105,7 +110,7 @@ body.html { filter: @cp_static-img-invert-filter; } - button { + button:not(.btn) { outline: none; background-color: @cp_buttons-primary; color: @cp_buttons-primary-text; diff --git a/customize.dist/src/less2/pages/page-recovery.less b/customize.dist/src/less2/pages/page-recovery.less new file mode 100644 index 000000000..2fe41447b --- /dev/null +++ b/customize.dist/src/less2/pages/page-recovery.less @@ -0,0 +1,99 @@ +@import (reference) "../include/infopages.less"; +@import (reference) "../include/colortheme-all.less"; +@import (reference) "../include/alertify.less"; +@import (reference) "../include/checkmark.less"; +@import (reference) "../include/forms.less"; + +&.cp-page-recovery { + .infopages_main(); + .forms_main(); + + .alertify_main(); + .checkmark_main(20px); + + .cp-container { + .alert { + font-size: @colortheme_app-font-size; + } + .form-group { + .cp-recovery-desc { + margin-bottom: 10px; + } + .cp-recovery-desc, .cp-recovery-step { + width: 100%; + } + #register { + &.btn { + padding: .5rem .5rem; + } + margin-top: 16px; + font-size: 1.25em; + min-width: 30%; + } + } + padding-bottom: 3em; + min-height: 5vh; + .cp-hidden { + display: none; + } + } + .alertify { + // workaround for alertify making empty p + p:empty { + display: none; + } + + nav { + display: flex; + align-items: center; + justify-content: flex-end; + } + + @media screen and (max-width: 600px) { + nav .btn-danger { + line-height: inherit; + } + } + + } + + .cp-recovery-det { + .cp-recover-button { + text-align: center; + } + .cp-recovery-method { + padding: 5px; + border: 1px solid white; + border-radius: 5px; + &:not(:last-child) { + margin-bottom: 10px; + } + h3 { + margin-top: 0; + } + } + .cp-recover-email { + height: 164px; + } + #userForm { + padding: 15px; + background-color: @cp_static-card-bg; + position: relative; + z-index: 2; + margin-bottom: 100px; + border-radius: @infopages-radius-L; + .cp-shadow(); + .form-control { + border-radius: @infopages-radius; + color: @cryptpad_text_col; + background-color: @cp_forms-bg; + margin-bottom: 10px; + &:focus { + border-color: @cryptpad_color_brand; + } + .tools_placeholder-color(); + } + } + } +} + diff --git a/customize.dist/template.js b/customize.dist/template.js index 332471630..b5eca8b2f 100644 --- a/customize.dist/template.js +++ b/customize.dist/template.js @@ -55,6 +55,8 @@ $(function () { require([ '/register/main.js' ], function () {}); } else if (/^\/install\//.test(pathname)) { require([ '/install/main.js' ], function () {}); + } else if (/^\/recovery\//.test(pathname)) { + require([ '/recovery/main.js' ], function () {}); } else if (/^\/login\//.test(pathname)) { require([ '/login/main.js' ], function () {}); } else if (/^\/($|^\/index\.html$)/.test(pathname)) { diff --git a/lib/challenge-commands/totp.js b/lib/challenge-commands/totp.js index 57b5257a6..8fcef5c38 100644 --- a/lib/challenge-commands/totp.js +++ b/lib/challenge-commands/totp.js @@ -23,6 +23,16 @@ var isValidOTP = otp => { !/\D/.test(otp); }; +// basic definition of what we'll accept as a recovery key +// 24 bytes encoded as b64 ==> 32 characters +var isValidRecoveryKey = otp => { + return isString(otp) && + // in the future this could be updated to support 8 digits + otp.length === 32 && + // \D is non-digit characters, so this tests that it is exclusively numeric + /[A-Za-z0-9+\/]{32}/.test(otp); +}; + // we'll only allow users to set up multi-factor auth // for keypairs they control which already have blocks // this check doesn't confirm that their id is valid base64 @@ -349,15 +359,15 @@ So, we should: // 3. They can produce a valid OTP for that block's TOTP secret // 4. They can sign for the block's public key const revoke = Commands.TOTP_REVOKE = function (Env, body, cb) { - var { publicKey, code } = body; + var { publicKey, code, recoveryKey } = body; // they must provide a valid OTP code - if (!isValidOTP(code)) { return void cb('E_INVALID'); } + if (!isValidOTP(code) && !isValidRecoveryKey(recoveryKey)) { return void cb('E_INVALID'); } // they must provide a valid block public key if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); } - var secret; + var secret, recoveryStored; nThen(function (w) { // check that there is an MFA configuration for the given account MFA.read(Env, publicKey, w(function (err, content) { @@ -378,7 +388,19 @@ const revoke = Commands.TOTP_REVOKE = function (Env, body, cb) { } secret = parsed.secret; + recoveryStored = parsed.contact; })); + }).nThen(function (w) { + if (!recoveryKey) { return; } + w.abort(); + if (!/^secret:/.test(recoveryStored)) { + return void cb("E_NO_RECOVERY_KEY"); + } + recoveryStored = recoveryStored.slice(7); + if (recoveryKey !== recoveryStored) { + return void cb("E_WRONG_RECOVERY_KEY"); + } + cb(); }).nThen(function () { let decoded = decode32(secret); if (!decoded) { diff --git a/www/common/common-interface.js b/www/common/common-interface.js index bd13a1e69..ea9574f34 100644 --- a/www/common/common-interface.js +++ b/www/common/common-interface.js @@ -985,6 +985,7 @@ define([ todo(); } + $('html').toggleClass('cp-loading-noscroll', true); // Remove the inner placeholder (iframe) $('#placeholder').remove(); }; @@ -1004,6 +1005,7 @@ define([ $loading.find('.cp-loading-progress').remove(); // Remove the progress list setTimeout(cb, 750); $('head > link[href^="/customize/src/pre-loading.css"]').remove(); + $('html').toggleClass('cp-loading-noscroll', false); }; UI.errorLoadingScreen = function (error, transparent, exitable) { if (error === 'Error: XDR encoding failure') { @@ -1044,6 +1046,7 @@ define([ $(window).keydown(function (e) { // XXX what if they don't have a keyboard? if (e.which === 27) { $loading.hide(); + $('html').toggleClass('cp-loading-noscroll', false); if (typeof(exitable) === "function") { exitable(); } } }); diff --git a/www/recovery/index.html b/www/recovery/index.html new file mode 100644 index 000000000..0a4a2242f --- /dev/null +++ b/www/recovery/index.html @@ -0,0 +1,15 @@ + + + +
+