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
This commit is contained in:
Matt Gibson 2021-07-21 07:55:26 -05:00 committed by GitHub
parent 00acbce556
commit 1006f50ef3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 143 additions and 47 deletions

View File

@ -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<AuthResult>;
onSuccessfulLogin: () => Promise<any>;
onSuccessfulLoginNavigate: () => Promise<any>;
@ -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();
}

View File

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

View File

@ -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<IdentityTokenResponse | IdentityTwoFactorResponse>;
postIdentityToken: (request: TokenRequest) => Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse>;
refreshIdentityToken: () => Promise<any>;
getProfile: () => Promise<ProfileResponse>;

View File

@ -14,7 +14,7 @@ export abstract class AuthService {
twoFactorProvidersData: Map<TwoFactorProviderType, { [key: string]: string; }>;
selectedTwoFactorProviderType: TwoFactorProviderType;
logIn: (email: string, masterPassword: string) => Promise<AuthResult>;
logIn: (email: string, masterPassword: string, captchaToken?: string) => Promise<AuthResult>;
logInSso: (code: string, codeVerifier: string, redirectUrl: string) => Promise<AuthResult>;
logInApiKey: (clientId: string, clientSecret: string) => Promise<AuthResult>;
logInTwoFactor: (twoFactorProvider: TwoFactorProviderType, twoFactorToken: string,

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import { TwoFactorProviderType } from '../../enums/twoFactorProviderType';
export class AuthResult {
twoFactor: boolean = false;
captchaSiteKey: string = '';
resetMasterPassword: boolean = false;
twoFactorProviders: Map<TwoFactorProviderType, { [key: string]: string; }> = null;
}

View File

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

View File

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

View File

@ -5,9 +5,11 @@ import { TwoFactorProviderType } from '../../enums/twoFactorProviderType';
export class IdentityTwoFactorResponse extends BaseResponse {
twoFactorProviders: TwoFactorProviderType[];
twoFactorProviders2 = new Map<TwoFactorProviderType, { [key: string]: string; }>();
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) {

View File

@ -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<IdentityTokenResponse | IdentityTwoFactorResponse> {
async postIdentityToken(request: TokenRequest): Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse> {
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);
}
}

View File

@ -87,6 +87,7 @@ export class AuthService implements AuthServiceAbstraction {
clientSecret: string;
twoFactorProvidersData: Map<TwoFactorProviderType, { [key: string]: string; }>;
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<AuthResult> {
async logIn(email: string, masterPassword: string, captchaToken?: string): Promise<AuthResult> {
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<AuthResult> {
@ -146,7 +147,7 @@ export class AuthService implements AuthServiceAbstraction {
remember?: boolean): Promise<AuthResult> {
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<AuthResult> {
twoFactorProvider?: TwoFactorProviderType, twoFactorToken?: string, remember?: boolean, captchaToken?: string): Promise<AuthResult> {
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;
}