From a73cbbb67287569ef0a049f3afa2fb32582b0585 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Fri, 23 Jul 2021 14:30:04 -0500 Subject: [PATCH] Feature/use hcaptcha if bot (#1089) * Add captcha to login page * pull out shared method * Update parse parameter logic * Load captcha * responsive iframe height * correct i18n * site key provided by server * Fix locale parsing * Add optional success callbackUri * Make captcha connector responsive * Handle parameter versions in webauthn * Move variables to top of script * Add captcha to registration * Move captcha above `
` div to be part of input form * Add styled mobile captcha connector * Linter Fixes * Remove duplicate import * Use listener to load captcha * PR review --- src/app/accounts/login.component.html | 3 +- src/app/accounts/register.component.html | 1 + src/app/accounts/register.component.ts | 2 + src/app/services/services.module.ts | 3 +- src/connectors/captcha-mobile.html | 22 ++++++ src/connectors/captcha-mobile.scss | 1 + src/connectors/captcha.html | 4 +- src/connectors/captcha.ts | 82 +++++++++++++++++---- src/connectors/common-webauthn.ts | 6 -- src/connectors/common.ts | 6 ++ src/connectors/webauthn-fallback.ts | 78 +++++++++++++++----- src/connectors/webauthn.ts | 94 +++++++++++++++++------- src/scss/styles.scss | 6 ++ webpack.config.js | 5 ++ 14 files changed, 247 insertions(+), 66 deletions(-) create mode 100644 src/connectors/captcha-mobile.html create mode 100644 src/connectors/captcha-mobile.scss diff --git a/src/app/accounts/login.component.html b/src/app/accounts/login.component.html index 97d5c4783a6..e2078d0b19c 100644 --- a/src/app/accounts/login.component.html +++ b/src/app/accounts/login.component.html @@ -30,11 +30,12 @@ {{'getMasterPasswordHint' | i18n}} -
+
+

+
+ + + + + + + + Bitwarden Captcha Connector + + + +
+
+ +

Captcha Required

+
+
+
+ + + diff --git a/src/connectors/captcha-mobile.scss b/src/connectors/captcha-mobile.scss new file mode 100644 index 00000000000..a4c7f9b25b7 --- /dev/null +++ b/src/connectors/captcha-mobile.scss @@ -0,0 +1 @@ +@import "../scss/styles.scss"; diff --git a/src/connectors/captcha.html b/src/connectors/captcha.html index 16aa5bdb63a..56795ec9a6c 100644 --- a/src/connectors/captcha.html +++ b/src/connectors/captcha.html @@ -3,8 +3,10 @@ + + + Bitwarden Captcha Connector - diff --git a/src/connectors/captcha.ts b/src/connectors/captcha.ts index 810189fc420..b625e2d796b 100644 --- a/src/connectors/captcha.ts +++ b/src/connectors/captcha.ts @@ -1,9 +1,14 @@ -import { getQsParam } from './common'; +import { b64Decode, getQsParam } from './common'; declare var hcaptcha: any; -// tslint:disable-next-line -require('./captcha.scss'); +if (window.location.pathname.includes('mobile')) { + // tslint:disable-next-line + require('./captcha-mobile.scss'); +} else { + // tslint:disable-next-line + require('./captcha.scss'); +} document.addEventListener('DOMContentLoaded', () => { init(); @@ -14,15 +19,15 @@ document.addEventListener('DOMContentLoaded', () => { let parentUrl: string = null; let parentOrigin: string = null; +let callbackUri: string = null; let sentSuccess = false; -function init() { - start(); +async function init() { + await start(); onMessage(); - info('ready'); } -function start() { +async function start() { sentSuccess = false; const data = getQsParam('data'); @@ -40,15 +45,49 @@ function start() { parentOrigin = new URL(parentUrl).origin; } - hcaptcha.render('captcha', { - sitekey: 'bc38c8a2-5311-4e8c-9dfc-49e99f6df417', - callback: 'captchaSuccess', - 'error-callback': 'captchaError', + let decodedData: any; + try { + decodedData = JSON.parse(b64Decode(data)); + } + catch (e) { + error('Cannot parse data.'); + return; + } + callbackUri = decodedData.callbackUri; + + let src = 'https://hcaptcha.com/1/api.js?render=explicit'; + + // Set language code + if (decodedData.locale) { + src += `&hl=${decodedData.locale ?? 'en'}`; + } + + // Set captchaRequired subtitle for mobile + const subtitleEl = document.getElementById('captchaRequired'); + if (decodedData.captchaRequiredText && subtitleEl) { + subtitleEl.textContent = decodedData.captchaRequiredText; + } + + const script = document.createElement('script'); + script.src = src; + script.async = true; + script.defer = true; + script.addEventListener('load', e => { + hcaptcha.render('captcha', { + sitekey: decodedData.siteKey, + callback: 'captchaSuccess', + 'error-callback': 'captchaError', + }); + watchHeight(); }); + document.head.appendChild(script); } function captchaSuccess(response: string) { success(response); + if (callbackUri) { + document.location.replace(callbackUri + '?token=' + encodeURIComponent(response)); + } } function captchaError() { @@ -79,7 +118,24 @@ function success(data: string) { sentSuccess = true; } -function info(message: string) { - parent.postMessage('info|' + message, parentUrl); +function info(message: string | object) { + parent.postMessage('info|' + JSON.stringify(message), parentUrl); +} + +async function watchHeight() { + const imagesDiv = document.body.lastChild as HTMLElement; + while (true) { + info({ + height: imagesDiv.style.visibility === 'hidden' ? + document.documentElement.offsetHeight : + document.documentElement.scrollHeight, + width: document.documentElement.scrollWidth, + }); + await sleep(100); + } +} + +async function sleep(ms: number) { + await new Promise(r => setTimeout(r, ms)); } diff --git a/src/connectors/common-webauthn.ts b/src/connectors/common-webauthn.ts index 907357fd539..93a6e3ccfbf 100644 --- a/src/connectors/common-webauthn.ts +++ b/src/connectors/common-webauthn.ts @@ -21,12 +21,6 @@ export function buildDataString(assertedCredential: PublicKeyCredential) { return JSON.stringify(data); } -export function b64Decode(str: string) { - return decodeURIComponent(Array.prototype.map.call(atob(str), (c: string) => { - return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); - }).join('')); -} - export function parseWebauthnJson(jsonString: string) { const json = JSON.parse(jsonString); diff --git a/src/connectors/common.ts b/src/connectors/common.ts index 08538656959..10431c19433 100644 --- a/src/connectors/common.ts +++ b/src/connectors/common.ts @@ -13,3 +13,9 @@ export function getQsParam(name: string) { return decodeURIComponent(results[2].replace(/\+/g, ' ')); } + +export function b64Decode(str: string) { + return decodeURIComponent(Array.prototype.map.call(atob(str), (c: string) => { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('')); +} diff --git a/src/connectors/webauthn-fallback.ts b/src/connectors/webauthn-fallback.ts index 7bd19353281..65802e55906 100644 --- a/src/connectors/webauthn-fallback.ts +++ b/src/connectors/webauthn-fallback.ts @@ -1,18 +1,70 @@ -import { getQsParam } from './common'; -import { b64Decode, buildDataString, parseWebauthnJson } from './common-webauthn'; +import { b64Decode, getQsParam } from './common'; +import { buildDataString, parseWebauthnJson } from './common-webauthn'; // tslint:disable-next-line require('./webauthn.scss'); +let parsed = false; +let webauthnJson: any; let parentUrl: string = null; let parentOrigin: string = null; let sentSuccess = false; +let locale: string = 'en'; let locales: any = {}; +function parseParameters() { + if (parsed) { + return; + } + + parentUrl = getQsParam('parent'); + if (!parentUrl) { + error('No parent.'); + return; + } else { + parentUrl = decodeURIComponent(parentUrl); + parentOrigin = new URL(parentUrl).origin; + } + + locale = getQsParam('locale').replace('-', '_'); + + const version = getQsParam('v'); + + if (version === '1') { + parseParametersV1(); + } else { + parseParametersV2(); + } + parsed = true; +} + +function parseParametersV1() { + const data = getQsParam('data'); + if (!data) { + error('No data.'); + return; + } + + webauthnJson = b64Decode(data); +} + +function parseParametersV2() { + let dataObj: { data: any, btnText: string; } = null; + try { + dataObj = JSON.parse(b64Decode(getQsParam('data'))); + } + catch (e) { + error('Cannot parse data.'); + return; + } + + webauthnJson = dataObj.data; +} + document.addEventListener('DOMContentLoaded', async () => { - const locale = getQsParam('locale').replace('-', '_'); + parseParameters(); try { locales = await loadLocales(locale); } catch { @@ -34,8 +86,8 @@ document.addEventListener('DOMContentLoaded', async () => { content.classList.remove('d-none'); }); -async function loadLocales(locale: string) { - const filePath = `locales/${locale}/messages.json?cache=${process.env.CACHE_TAG}`; +async function loadLocales(newLocale: string) { + const filePath = `locales/${newLocale}/messages.json?cache=${process.env.CACHE_TAG}`; const localesResult = await fetch(filePath); return await localesResult.json(); } @@ -54,25 +106,15 @@ function start() { return; } - const data = getQsParam('data'); - if (!data) { + parseParameters(); + if (!webauthnJson) { error('No data.'); return; } - parentUrl = getQsParam('parent'); - if (!parentUrl) { - error('No parent.'); - return; - } else { - parentUrl = decodeURIComponent(parentUrl); - parentOrigin = new URL(parentUrl).origin; - } - let json: any; try { - const jsonString = b64Decode(data); - json = parseWebauthnJson(jsonString); + json = parseWebauthnJson(webauthnJson); } catch (e) { error('Cannot parse data.'); diff --git a/src/connectors/webauthn.ts b/src/connectors/webauthn.ts index 79ad8c1687a..2c1af50d332 100644 --- a/src/connectors/webauthn.ts +++ b/src/connectors/webauthn.ts @@ -1,43 +1,37 @@ -import { getQsParam } from './common'; -import { b64Decode, buildDataString, parseWebauthnJson } from './common-webauthn'; +import { b64Decode, getQsParam } from './common'; +import { buildDataString, parseWebauthnJson } from './common-webauthn'; // tslint:disable-next-line require('./webauthn.scss'); -document.addEventListener('DOMContentLoaded', () => { - init(); - - const text = getQsParam('btnText'); - if (text) { - const button = document.getElementById('webauthn-button'); - button.innerText = decodeURI(text); - button.onclick = executeWebAuthn; - } -}); - +let parsed = false; +let webauthnJson: any; +let btnText: string = null; let parentUrl: string = null; let parentOrigin: string = null; let stopWebAuthn = false; let sentSuccess = false; let obj: any = null; +document.addEventListener('DOMContentLoaded', () => { + init(); + + parseParameters(); + if (btnText) { + const button = document.getElementById('webauthn-button'); + button.innerText = decodeURI(btnText); + button.onclick = executeWebAuthn; + } +}); + function init() { start(); onMessage(); info('ready'); } -function start() { - sentSuccess = false; - - if (!('credentials' in navigator)) { - error('WebAuthn is not supported in this browser.'); - return; - } - - const data = getQsParam('data'); - if (!data) { - error('No data.'); +function parseParameters() { + if (parsed) { return; } @@ -50,15 +44,63 @@ function start() { parentOrigin = new URL(parentUrl).origin; } + const version = getQsParam('v'); + + if (version === '1') { + parseParametersV1(); + } else { + parseParametersV2(); + } + parsed = true; +} + +function parseParametersV1() { + const data = getQsParam('data'); + if (!data) { + error('No data.'); + return; + } + + webauthnJson = b64Decode(data); + btnText = getQsParam('btnText'); +} + +function parseParametersV2() { + let dataObj: { data: any, btnText: string; } = null; try { - const jsonString = b64Decode(data); - obj = parseWebauthnJson(jsonString); + dataObj = JSON.parse(b64Decode(getQsParam('data'))); } catch (e) { error('Cannot parse data.'); return; } + webauthnJson = dataObj.data; + btnText = dataObj.btnText; +} + +function start() { + sentSuccess = false; + + if (!('credentials' in navigator)) { + error('WebAuthn is not supported in this browser.'); + return; + } + + parseParameters(); + if (!webauthnJson) { + error('No data.'); + return; + } + + try { + obj = parseWebauthnJson(webauthnJson); + } + catch (e) { + error('Cannot parse webauthn data.'); + return; + } + stopWebAuthn = false; if (navigator.userAgent.indexOf(' Safari/') !== -1 && navigator.userAgent.indexOf('Chrome') === -1) { diff --git a/src/scss/styles.scss b/src/scss/styles.scss index 598fea83b30..c71abd2bf7c 100644 --- a/src/scss/styles.scss +++ b/src/scss/styles.scss @@ -615,6 +615,12 @@ app-user-billing { } } +#hcaptcha_iframe { + width: 100%; + border: none; + transition: height 0.25s linear; +} + #bt-dropin-container { background: url('../images/loading.svg') 0 0 no-repeat; min-height: 50px; diff --git a/webpack.config.js b/webpack.config.js index c3899731337..718cd75eb87 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -112,6 +112,11 @@ const plugins = [ filename: 'captcha-connector.html', chunks: ['connectors/captcha'], }), + new HtmlWebpackPlugin({ + template: './src/connectors/captcha-mobile.html', + filename: 'captcha-mobile-connector.html', + chunks: ['connectors/captcha'], + }), new CopyWebpackPlugin({ patterns:[ { from: './src/.nojekyll' },