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:
parent
2b5f61cadd
commit
a73cbbb672
@ -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">
|
||||
|
@ -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"
|
||||
|
@ -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() {
|
||||
|
@ -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 },
|
||||
|
22
src/connectors/captcha-mobile.html
Normal file
22
src/connectors/captcha-mobile.html
Normal 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>
|
1
src/connectors/captcha-mobile.scss
Normal file
1
src/connectors/captcha-mobile.scss
Normal file
@ -0,0 +1 @@
|
||||
@import "../scss/styles.scss";
|
@ -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>
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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(''));
|
||||
}
|
||||
|
@ -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.');
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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' },
|
||||
|
Loading…
Reference in New Issue
Block a user