1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-27 17:18:04 +01:00

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 `<hr>` div to be part of input form

* Add styled mobile captcha connector

* Linter Fixes

* Remove duplicate import

* Use listener to load captcha

* PR review
This commit is contained in:
Matt Gibson 2021-07-23 14:30:04 -05:00 committed by GitHub
parent 2b5f61cadd
commit a73cbbb672
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 247 additions and 66 deletions

View File

@ -30,11 +30,12 @@
<a routerLink="/hint">{{'getMasterPasswordHint' | i18n}}</a>
</small>
</div>
<div class="form-check">
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="rememberEmail" name="RememberEmail"
[(ngModel)]="rememberEmail">
<label class="form-check-label" for="rememberEmail">{{'rememberEmail' | i18n}}</label>
</div>
<div class="mb-n3" [hidden]="!showCaptcha()"><iframe id="hcaptcha_iframe" height="80"></iframe></div>
<hr>
<div class="d-flex">
<button type="submit" class="btn btn-primary btn-block btn-submit" [disabled]="form.loading">

View File

@ -121,6 +121,7 @@
<input id="hint" class="form-control" type="text" name="Hint" [(ngModel)]="hint">
<small class="form-text text-muted">{{'masterPassHintDesc' | i18n}}</small>
</div>
<div [hidden]="!showCaptcha()"><iframe id="hcaptcha_iframe" height="80"></iframe></div>
<div class="form-group" *ngIf="showTerms">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="acceptPolicies"

View File

@ -107,6 +107,8 @@ export class RegisterComponent extends BaseRegisterComponent {
if (this.policies != null) {
this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions(this.policies);
}
await super.ngOnInit();
}
async submit() {

View File

@ -54,7 +54,6 @@ import { UserService } from 'jslib-common/services/user.service';
import { VaultTimeoutService } from 'jslib-common/services/vaultTimeout.service';
import { WebCryptoFunctionService } from 'jslib-common/services/webCryptoFunction.service';
import { LogService } from 'jslib-common/abstractions';
import { ApiService as ApiServiceAbstraction } from 'jslib-common/abstractions/api.service';
import { AuditService as AuditServiceAbstraction } from 'jslib-common/abstractions/audit.service';
import { AuthService as AuthServiceAbstraction } from 'jslib-common/abstractions/auth.service';
@ -69,6 +68,7 @@ import { FileUploadService as FileUploadServiceAbstraction } from 'jslib-common
import { FolderService as FolderServiceAbstraction } from 'jslib-common/abstractions/folder.service';
import { I18nService as I18nServiceAbstraction } from 'jslib-common/abstractions/i18n.service';
import { ImportService as ImportServiceAbstraction } from 'jslib-common/abstractions/import.service';
import { LogService } from 'jslib-common/abstractions/log.service';
import { MessagingService as MessagingServiceAbstraction } from 'jslib-common/abstractions/messaging.service';
import { NotificationsService as NotificationsServiceAbstraction } from 'jslib-common/abstractions/notifications.service';
import {
@ -194,6 +194,7 @@ export function initFactory(): Function {
{ provide: AuthServiceAbstraction, useValue: authService },
{ provide: CipherServiceAbstraction, useValue: cipherService },
{ provide: FolderServiceAbstraction, useValue: folderService },
{ provide: LogService, useValue: consoleLogService },
{ provide: CollectionServiceAbstraction, useValue: collectionService },
{ provide: EnvironmentServiceAbstraction, useValue: environmentService },
{ provide: TotpServiceAbstraction, useValue: totpService },

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="HandheldFriendly" content="true">
<title>Bitwarden Captcha Connector</title>
</head>
<body class="layout_frontend">
<div class="row justify-content-md-center mt-5">
<div>
<img src="..//images/logo-dark@2x.png" class="logo mb-2" alt="Bitwarden">
<p id="captchaRequired" class="lead text-center mx-4 mb-4">Captcha Required</p>
<div id="captcha"></div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1 @@
@import "../scss/styles.scss";

View File

@ -3,8 +3,10 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="HandheldFriendly" content="true">
<title>Bitwarden Captcha Connector</title>
<script src="https://hcaptcha.com/1/api.js"></script>
</head>
<body>

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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