From 1006f50ef334bbad1b820bf0592e096280d100f9 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 21 Jul 2021 07:55:26 -0500 Subject: [PATCH] Feature/use hcaptcha if bot (#430) * Handle hcaptch required identity response * Refactor iframe component for captcha and webauthn * Send captcha token to server * Add captcha callback * Clear captcha state * Remove captcha storage * linter fixes * Rename iframe components to include IFrame * Remove callback in favor of extenting submit * Limit publickey credentials access * Use captcha bypass token to bypass captcha for twofactor auth flows * Linter fixes * Set iframe version in components --- angular/src/components/login.component.ts | 28 ++++++++++- .../src/components/two-factor.component.ts | 6 +-- common/src/abstractions/api.service.ts | 3 +- common/src/abstractions/auth.service.ts | 2 +- common/src/misc/captcha_iframe.ts | 22 +++++++++ .../misc/{webauthn.ts => iframe_component.ts} | 49 ++++++++----------- common/src/misc/webauthn_iframe.ts | 26 ++++++++++ common/src/models/domain/authResult.ts | 1 + common/src/models/request/tokenRequest.ts | 9 +++- .../response/identityCaptchaResponse.ts | 10 ++++ .../response/identityTwoFactorResponse.ts | 2 + common/src/services/api.service.ts | 6 ++- common/src/services/auth.service.ts | 26 ++++++---- 13 files changed, 143 insertions(+), 47 deletions(-) create mode 100644 common/src/misc/captcha_iframe.ts rename common/src/misc/{webauthn.ts => iframe_component.ts} (58%) create mode 100644 common/src/misc/webauthn_iframe.ts create mode 100644 common/src/models/response/identityCaptchaResponse.ts diff --git a/angular/src/components/login.component.ts b/angular/src/components/login.component.ts index 22c9736a93..1fd4be89b3 100644 --- a/angular/src/components/login.component.ts +++ b/angular/src/components/login.component.ts @@ -19,6 +19,7 @@ import { StorageService } from 'jslib-common/abstractions/storage.service'; import { ConstantsService } from 'jslib-common/services/constants.service'; +import { CaptchaIFrame } from 'jslib-common/misc/captcha_iframe'; import { Utils } from 'jslib-common/misc/utils'; const Keys = { @@ -33,6 +34,9 @@ export class LoginComponent implements OnInit { masterPassword: string = ''; showPassword: boolean = false; + captchaSiteKey: string = null; + captchaToken: string = null; + captcha: CaptchaIFrame; formPromise: Promise; onSuccessfulLogin: () => Promise; onSuccessfulLoginNavigate: () => Promise; @@ -61,6 +65,20 @@ export class LoginComponent implements OnInit { if (Utils.isBrowser && !Utils.isNode) { this.focusInput(); } + + let webVaultUrl = this.environmentService.getWebVaultUrl(); + if (webVaultUrl == null) { + webVaultUrl = 'https://vault.bitwarden.com'; + } + this.captcha = new CaptchaIFrame(window, webVaultUrl, + this.i18nService, (token: string) => { + this.captchaToken = token; + }, (error: string) => { + this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), error); + }, (info: string) => { + this.platformUtilsService.showToast('info', this.i18nService.t('info'), info); + } + ); } async submit() { @@ -81,7 +99,7 @@ export class LoginComponent implements OnInit { } try { - this.formPromise = this.authService.logIn(this.email, this.masterPassword); + this.formPromise = this.authService.logIn(this.email, this.masterPassword, this.captchaToken); const response = await this.formPromise; await this.storageService.save(Keys.rememberEmail, this.rememberEmail); if (this.rememberEmail) { @@ -89,7 +107,10 @@ export class LoginComponent implements OnInit { } else { await this.storageService.remove(Keys.rememberedEmail); } - if (response.twoFactor) { + if (!Utils.isNullOrWhitespace(response.captchaSiteKey)) { + this.captchaSiteKey = response.captchaSiteKey; + this.captcha.init(response.captchaSiteKey); + } else if (response.twoFactor) { if (this.onSuccessfulLoginTwoFactorNavigate != null) { this.onSuccessfulLoginTwoFactorNavigate(); } else { @@ -144,6 +165,9 @@ export class LoginComponent implements OnInit { '&state=' + state + '&codeChallenge=' + codeChallenge); } + showCaptcha() { + return !Utils.isNullOrWhitespace(this.captchaSiteKey); + } protected focusInput() { document.getElementById(this.email == null || this.email === '' ? 'email' : 'masterPassword').focus(); } diff --git a/angular/src/components/two-factor.component.ts b/angular/src/components/two-factor.component.ts index f8dc22e374..4e1d538d4d 100644 --- a/angular/src/components/two-factor.component.ts +++ b/angular/src/components/two-factor.component.ts @@ -23,7 +23,7 @@ import { TwoFactorProviders } from 'jslib-common/services/auth.service'; import { ConstantsService } from 'jslib-common/services/constants.service'; import * as DuoWebSDK from 'duo_web_sdk'; -import { WebAuthn } from 'jslib-common/misc/webauthn'; +import { WebAuthnIFrame } from 'jslib-common/misc/webauthn_iframe'; @Directive() export class TwoFactorComponent implements OnInit, OnDestroy { @@ -35,7 +35,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy { providerType = TwoFactorProviderType; selectedProviderType: TwoFactorProviderType = TwoFactorProviderType.Authenticator; webAuthnSupported: boolean = false; - webAuthn: WebAuthn = null; + webAuthn: WebAuthnIFrame = null; title: string = ''; twoFactorEmail: string = null; formPromise: Promise; @@ -80,7 +80,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy { if (webVaultUrl == null) { webVaultUrl = 'https://vault.bitwarden.com'; } - this.webAuthn = new WebAuthn(this.win, webVaultUrl, this.webAuthnNewTab, this.platformUtilsService, + this.webAuthn = new WebAuthnIFrame(this.win, webVaultUrl, this.webAuthnNewTab, this.platformUtilsService, this.i18nService, (token: string) => { this.token = token; this.submit(); diff --git a/common/src/abstractions/api.service.ts b/common/src/abstractions/api.service.ts index bc8c4d87e8..c5da7a0ff3 100644 --- a/common/src/abstractions/api.service.ts +++ b/common/src/abstractions/api.service.ts @@ -108,6 +108,7 @@ import { GroupDetailsResponse, GroupResponse, } from '../models/response/groupResponse'; +import { IdentityCaptchaResponse } from '../models/response/identityCaptchaResponse'; import { IdentityTokenResponse } from '../models/response/identityTokenResponse'; import { IdentityTwoFactorResponse } from '../models/response/identityTwoFactorResponse'; import { ListResponse } from '../models/response/listResponse'; @@ -158,7 +159,7 @@ export abstract class ApiService { eventsBaseUrl: string; setUrls: (urls: EnvironmentUrls) => void; - postIdentityToken: (request: TokenRequest) => Promise; + postIdentityToken: (request: TokenRequest) => Promise; refreshIdentityToken: () => Promise; getProfile: () => Promise; diff --git a/common/src/abstractions/auth.service.ts b/common/src/abstractions/auth.service.ts index ac7ef048d0..00a2b65e08 100644 --- a/common/src/abstractions/auth.service.ts +++ b/common/src/abstractions/auth.service.ts @@ -14,7 +14,7 @@ export abstract class AuthService { twoFactorProvidersData: Map; selectedTwoFactorProviderType: TwoFactorProviderType; - logIn: (email: string, masterPassword: string) => Promise; + logIn: (email: string, masterPassword: string, captchaToken?: string) => Promise; logInSso: (code: string, codeVerifier: string, redirectUrl: string) => Promise; logInApiKey: (clientId: string, clientSecret: string) => Promise; logInTwoFactor: (twoFactorProvider: TwoFactorProviderType, twoFactorToken: string, diff --git a/common/src/misc/captcha_iframe.ts b/common/src/misc/captcha_iframe.ts new file mode 100644 index 0000000000..c098288dea --- /dev/null +++ b/common/src/misc/captcha_iframe.ts @@ -0,0 +1,22 @@ +import { I18nService } from '../abstractions/i18n.service'; +import { IFrameComponent } from './iframe_component'; + +export class CaptchaIFrame extends IFrameComponent { + constructor(win: Window, webVaultUrl: string, + private i18nService: I18nService, successCallback: (message: string) => any, errorCallback: (message: string) => any, + infoCallback: (message: string) => any) { + super(win, webVaultUrl, 'captcha-connector.html', 'hcaptcha_iframe', successCallback, errorCallback, (message: string) => { + const parsedMessage = JSON.parse(message); + if (typeof (parsedMessage) !== 'string') { + this.iframe.height = (parsedMessage.height).toString(); + this.iframe.width = (parsedMessage.width).toString(); + } else { + infoCallback(parsedMessage); + } + }); + } + + init(siteKey: string): void { + super.initComponent(this.createParams({ siteKey: siteKey, locale: this.i18nService.translationLocale }, 1)); + } +} diff --git a/common/src/misc/webauthn.ts b/common/src/misc/iframe_component.ts similarity index 58% rename from common/src/misc/webauthn.ts rename to common/src/misc/iframe_component.ts index e6d2583343..0e04dee035 100644 --- a/common/src/misc/webauthn.ts +++ b/common/src/misc/iframe_component.ts @@ -1,39 +1,16 @@ import { I18nService } from '../abstractions/i18n.service'; -import { PlatformUtilsService } from '../abstractions/platformUtils.service'; -export class WebAuthn { - private iframe: HTMLIFrameElement = null; +export abstract class IFrameComponent { + iframe: HTMLIFrameElement; private connectorLink: HTMLAnchorElement; private parseFunction = this.parseMessage.bind(this); - constructor(private win: Window, private webVaultUrl: string, private webAuthnNewTab: boolean, - private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, - private successCallback: Function, private errorCallback: Function, private infoCallback: Function) { + constructor(private win: Window, protected webVaultUrl: string, private path: string, private iframeId: string, + public successCallback?: (message: string) => any, + public errorCallback?: (message: string) => any, public infoCallback?: (message: string) => any) { this.connectorLink = win.document.createElement('a'); } - init(data: any): void { - const params = new URLSearchParams({ - data: this.base64Encode(JSON.stringify(data)), - parent: encodeURIComponent(this.win.document.location.href), - btnText: encodeURIComponent(this.i18nService.t('webAuthnAuthenticate')), - v: '1', - }); - - if (this.webAuthnNewTab) { - // Firefox fallback which opens the webauthn page in a new tab - params.append('locale', this.i18nService.translationLocale); - this.platformUtilsService.launchUri(`${this.webVaultUrl}/webauthn-fallback-connector.html?${params}`); - } else { - this.connectorLink.href = `${this.webVaultUrl}/webauthn-connector.html?${params}`; - this.iframe = this.win.document.getElementById('webauthn_iframe') as HTMLIFrameElement; - this.iframe.allow = 'publickey-credentials-get ' + new URL(this.webVaultUrl).origin; - this.iframe.src = this.connectorLink.href; - - this.win.addEventListener('message', this.parseFunction, false); - } - } - stop() { this.sendMessage('stop'); } @@ -60,6 +37,22 @@ export class WebAuthn { this.win.removeEventListener('message', this.parseFunction, false); } + protected createParams(data: any, version: number) { + return new URLSearchParams({ + data: this.base64Encode(JSON.stringify(data)), + parent: encodeURIComponent(this.win.document.location.href), + v: version.toString(), + }); + } + + protected initComponent(params: URLSearchParams): void { + this.connectorLink.href = `${this.webVaultUrl}/${this.path}?${params}`; + this.iframe = this.win.document.getElementById(this.iframeId) as HTMLIFrameElement; + this.iframe.src = this.connectorLink.href; + + this.win.addEventListener('message', this.parseFunction, false); + } + private parseMessage(event: MessageEvent) { if (!this.validMessage(event)) { return; diff --git a/common/src/misc/webauthn_iframe.ts b/common/src/misc/webauthn_iframe.ts new file mode 100644 index 0000000000..50870c5794 --- /dev/null +++ b/common/src/misc/webauthn_iframe.ts @@ -0,0 +1,26 @@ +import { I18nService } from '../abstractions/i18n.service'; +import { PlatformUtilsService } from '../abstractions/platformUtils.service'; +import { IFrameComponent } from './iframe_component'; + +export class WebAuthnIFrame extends IFrameComponent { + constructor(win: Window, webVaultUrl: string, private webAuthnNewTab: boolean, + private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, + successCallback: (message: string) => any, errorCallback: (message: string) => any, + infoCallback: (message: string) => any) { + super(win, webVaultUrl, 'webauthn-connector.html', 'webauthn_iframe', successCallback, errorCallback, infoCallback); + } + + + init(data: any): void { + const params = this.createParams({ data: JSON.stringify(data), btnText: this.i18nService.t('webAuthnAuthenticate') }, 2); + + if (this.webAuthnNewTab) { + // Firefox fallback which opens the webauthn page in a new tab + params.append('locale', this.i18nService.translationLocale); + this.platformUtilsService.launchUri(`${this.webVaultUrl}/webauthn-fallback-connector.html?${params}`); + } else { + super.initComponent(params); + this.iframe.allow = 'publickey-credentials-get ' + new URL(this.webVaultUrl).origin; + } + } +} diff --git a/common/src/models/domain/authResult.ts b/common/src/models/domain/authResult.ts index 0f5b95a521..fac5d903ac 100644 --- a/common/src/models/domain/authResult.ts +++ b/common/src/models/domain/authResult.ts @@ -2,6 +2,7 @@ import { TwoFactorProviderType } from '../../enums/twoFactorProviderType'; export class AuthResult { twoFactor: boolean = false; + captchaSiteKey: string = ''; resetMasterPassword: boolean = false; twoFactorProviders: Map = null; } diff --git a/common/src/models/request/tokenRequest.ts b/common/src/models/request/tokenRequest.ts index 757801279c..3cad488aee 100644 --- a/common/src/models/request/tokenRequest.ts +++ b/common/src/models/request/tokenRequest.ts @@ -13,10 +13,11 @@ export class TokenRequest { token: string; provider: TwoFactorProviderType; remember: boolean; + captchaToken: string; device?: DeviceRequest; constructor(credentials: string[], codes: string[], clientIdClientSecret: string[], provider: TwoFactorProviderType, - token: string, remember: boolean, device?: DeviceRequest) { + token: string, remember: boolean, captchaToken: string, device?: DeviceRequest) { if (credentials != null && credentials.length > 1) { this.email = credentials[0]; this.masterPasswordHash = credentials[1]; @@ -32,6 +33,7 @@ export class TokenRequest { this.provider = provider; this.remember = remember; this.device = device != null ? device : null; + this.captchaToken = captchaToken; } toIdentityToken(clientId: string) { @@ -71,6 +73,11 @@ export class TokenRequest { obj.twoFactorRemember = this.remember ? '1' : '0'; } + if (this.captchaToken != null) { + obj.captchaResponse = this.captchaToken; + } + + return obj; } diff --git a/common/src/models/response/identityCaptchaResponse.ts b/common/src/models/response/identityCaptchaResponse.ts new file mode 100644 index 0000000000..082423e34d --- /dev/null +++ b/common/src/models/response/identityCaptchaResponse.ts @@ -0,0 +1,10 @@ +import { BaseResponse } from './baseResponse'; + +export class IdentityCaptchaResponse extends BaseResponse { + siteKey: string; + + constructor(response: any) { + super(response); + this.siteKey = this.getResponseProperty('HCaptcha_SiteKey'); + } +} diff --git a/common/src/models/response/identityTwoFactorResponse.ts b/common/src/models/response/identityTwoFactorResponse.ts index ea92a5885c..e188662937 100644 --- a/common/src/models/response/identityTwoFactorResponse.ts +++ b/common/src/models/response/identityTwoFactorResponse.ts @@ -5,9 +5,11 @@ import { TwoFactorProviderType } from '../../enums/twoFactorProviderType'; export class IdentityTwoFactorResponse extends BaseResponse { twoFactorProviders: TwoFactorProviderType[]; twoFactorProviders2 = new Map(); + captchaToken: string; constructor(response: any) { super(response); + this.captchaToken = this.getResponseProperty('HCaptcha_BypassKey'); this.twoFactorProviders = this.getResponseProperty('TwoFactorProviders'); const twoFactorProviders2 = this.getResponseProperty('TwoFactorProviders2'); if (twoFactorProviders2 != null) { diff --git a/common/src/services/api.service.ts b/common/src/services/api.service.ts index 1d3fed88da..aed2a2578b 100644 --- a/common/src/services/api.service.ts +++ b/common/src/services/api.service.ts @@ -160,6 +160,7 @@ import { ChallengeResponse } from '../models/response/twoFactorWebAuthnResponse' import { TwoFactorYubiKeyResponse } from '../models/response/twoFactorYubiKeyResponse'; import { UserKeyResponse } from '../models/response/userKeyResponse'; +import { IdentityCaptchaResponse } from '../models/response/identityCaptchaResponse'; import { SendAccessView } from '../models/view/sendAccessView'; export class ApiService implements ApiServiceAbstraction { @@ -215,7 +216,7 @@ export class ApiService implements ApiServiceAbstraction { // Auth APIs - async postIdentityToken(request: TokenRequest): Promise { + async postIdentityToken(request: TokenRequest): Promise { const headers = new Headers({ 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', 'Accept': 'application/json', @@ -245,6 +246,9 @@ export class ApiService implements ApiServiceAbstraction { Object.keys(responseJson.TwoFactorProviders2).length) { await this.tokenService.clearTwoFactorToken(request.email); return new IdentityTwoFactorResponse(responseJson); + } else if (response.status === 400 && responseJson.HCaptcha_SiteKey && + Object.keys(responseJson.HCaptcha_SiteKey).length) { + return new IdentityCaptchaResponse(responseJson); } } diff --git a/common/src/services/auth.service.ts b/common/src/services/auth.service.ts index 6536a94c3d..59771ec4e9 100644 --- a/common/src/services/auth.service.ts +++ b/common/src/services/auth.service.ts @@ -87,6 +87,7 @@ export class AuthService implements AuthServiceAbstraction { clientSecret: string; twoFactorProvidersData: Map; selectedTwoFactorProviderType: TwoFactorProviderType = null; + captchaToken: string; private key: SymmetricCryptoKey; @@ -120,14 +121,14 @@ export class AuthService implements AuthServiceAbstraction { TwoFactorProviders[TwoFactorProviderType.Yubikey].description = this.i18nService.t('yubiKeyDesc'); } - async logIn(email: string, masterPassword: string): Promise { + async logIn(email: string, masterPassword: string, captchaToken?: string): Promise { this.selectedTwoFactorProviderType = null; const key = await this.makePreloginKey(masterPassword, email); const hashedPassword = await this.cryptoService.hashPassword(masterPassword, key); const localHashedPassword = await this.cryptoService.hashPassword(masterPassword, key, HashPurpose.LocalAuthorization); return await this.logInHelper(email, hashedPassword, localHashedPassword, null, null, null, null, null, - key, null, null, null); + key, null, null, null, captchaToken); } async logInSso(code: string, codeVerifier: string, redirectUrl: string): Promise { @@ -146,7 +147,7 @@ export class AuthService implements AuthServiceAbstraction { remember?: boolean): Promise { return await this.logInHelper(this.email, this.masterPasswordHash, this.localMasterPasswordHash, this.code, this.codeVerifier, this.ssoRedirectUrl, this.clientId, this.clientSecret, this.key, twoFactorProvider, - twoFactorToken, remember); + twoFactorToken, remember, this.captchaToken); } async logInComplete(email: string, masterPassword: string, twoFactorProvider: TwoFactorProviderType, @@ -272,7 +273,7 @@ export class AuthService implements AuthServiceAbstraction { private async logInHelper(email: string, hashedPassword: string, localHashedPassword: string, code: string, codeVerifier: string, redirectUrl: string, clientId: string, clientSecret: string, key: SymmetricCryptoKey, - twoFactorProvider?: TwoFactorProviderType, twoFactorToken?: string, remember?: boolean): Promise { + twoFactorProvider?: TwoFactorProviderType, twoFactorToken?: string, remember?: boolean, captchaToken?: string): Promise { const storedTwoFactorToken = await this.tokenService.getTwoFactorToken(email); const appId = await this.appIdService.getAppId(); const deviceRequest = new DeviceRequest(appId, this.platformUtilsService); @@ -300,24 +301,27 @@ export class AuthService implements AuthServiceAbstraction { let request: TokenRequest; if (twoFactorToken != null && twoFactorProvider != null) { request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, twoFactorProvider, - twoFactorToken, remember, deviceRequest); + twoFactorToken, remember, captchaToken, deviceRequest); } else if (storedTwoFactorToken != null) { - request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, TwoFactorProviderType.Remember, - storedTwoFactorToken, false, deviceRequest); + request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, + TwoFactorProviderType.Remember, storedTwoFactorToken, false, captchaToken, deviceRequest); } else { request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, null, - null, false, deviceRequest); + null, false, captchaToken, deviceRequest); } const response = await this.apiService.postIdentityToken(request); this.clearState(); const result = new AuthResult(); - result.twoFactor = !(response as any).accessToken; + result.captchaSiteKey = (response as any).siteKey; + if (!!result.captchaSiteKey) { + return result; + } + result.twoFactor = !!(response as any).twoFactorProviders2; if (result.twoFactor) { // two factor required - const twoFactorResponse = response as IdentityTwoFactorResponse; this.email = email; this.masterPasswordHash = hashedPassword; this.localMasterPasswordHash = localHashedPassword; @@ -327,8 +331,10 @@ export class AuthService implements AuthServiceAbstraction { this.clientId = clientId; this.clientSecret = clientSecret; this.key = this.setCryptoKeys ? key : null; + const twoFactorResponse = response as IdentityTwoFactorResponse; this.twoFactorProvidersData = twoFactorResponse.twoFactorProviders2; result.twoFactorProviders = twoFactorResponse.twoFactorProviders2; + this.captchaToken = twoFactorResponse.captchaToken; return result; }