diff --git a/angular/src/components/captchaProtected.component.ts b/angular/src/components/captchaProtected.component.ts new file mode 100644 index 00000000000..b14838650e7 --- /dev/null +++ b/angular/src/components/captchaProtected.component.ts @@ -0,0 +1,50 @@ +import { Directive, Input } from '@angular/core'; + +import { EnvironmentService } from 'jslib-common/abstractions/environment.service'; +import { I18nService } from 'jslib-common/abstractions/i18n.service'; +import { PlatformUtilsService } from 'jslib-common/abstractions/platformUtils.service'; + +import { CaptchaIFrame } from 'jslib-common/misc/captcha_iframe'; + +import { Utils } from 'jslib-common/misc/utils'; + +@Directive() +export abstract class CaptchaProtectedComponent { + @Input() captchaSiteKey: string = null; + captchaToken: string = null; + captcha: CaptchaIFrame; + + constructor(protected environmentService: EnvironmentService, protected i18nService: I18nService, + protected platformUtilsService: PlatformUtilsService) { } + + async setupCaptcha() { + 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); + } + ); + } + + showCaptcha() { + return !Utils.isNullOrWhitespace(this.captchaSiteKey); + } + + protected handleCaptchaRequired(response: { captchaSiteKey: string; }): boolean { + if (Utils.isNullOrWhitespace(response.captchaSiteKey)) { + return false; + } + + this.captchaSiteKey = response.captchaSiteKey; + this.captcha.init(response.captchaSiteKey); + return true; + } +} diff --git a/angular/src/components/login.component.ts b/angular/src/components/login.component.ts index 1fd4be89b3f..7bae8b2b8b8 100644 --- a/angular/src/components/login.component.ts +++ b/angular/src/components/login.component.ts @@ -19,24 +19,22 @@ 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'; +import { CaptchaProtectedComponent } from './captchaProtected.component'; + const Keys = { rememberedEmail: 'rememberedEmail', rememberEmail: 'rememberEmail', }; @Directive() -export class LoginComponent implements OnInit { +export class LoginComponent extends CaptchaProtectedComponent implements OnInit { @Input() email: string = ''; @Input() rememberEmail = true; masterPassword: string = ''; showPassword: boolean = false; - captchaSiteKey: string = null; - captchaToken: string = null; - captcha: CaptchaIFrame; formPromise: Promise; onSuccessfulLogin: () => Promise; onSuccessfulLoginNavigate: () => Promise; @@ -46,10 +44,12 @@ export class LoginComponent implements OnInit { protected successRoute = 'vault'; constructor(protected authService: AuthService, protected router: Router, - protected platformUtilsService: PlatformUtilsService, protected i18nService: I18nService, - protected stateService: StateService, protected environmentService: EnvironmentService, + platformUtilsService: PlatformUtilsService, i18nService: I18nService, + protected stateService: StateService, environmentService: EnvironmentService, protected passwordGenerationService: PasswordGenerationService, - protected cryptoFunctionService: CryptoFunctionService, private storageService: StorageService) { } + protected cryptoFunctionService: CryptoFunctionService, private storageService: StorageService) { + super(environmentService, i18nService, platformUtilsService); + } async ngOnInit() { if (this.email == null || this.email === '') { @@ -66,19 +66,7 @@ export class LoginComponent implements OnInit { 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); - } - ); + this.setupCaptcha(); } async submit() { @@ -107,9 +95,8 @@ export class LoginComponent implements OnInit { } else { await this.storageService.remove(Keys.rememberedEmail); } - if (!Utils.isNullOrWhitespace(response.captchaSiteKey)) { - this.captchaSiteKey = response.captchaSiteKey; - this.captcha.init(response.captchaSiteKey); + if (this.handleCaptchaRequired(response)) { + return; } else if (response.twoFactor) { if (this.onSuccessfulLoginTwoFactorNavigate != null) { this.onSuccessfulLoginTwoFactorNavigate(); @@ -165,9 +152,6 @@ 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/register.component.ts b/angular/src/components/register.component.ts index 53ec3c8bedc..aecf9ceb326 100644 --- a/angular/src/components/register.component.ts +++ b/angular/src/components/register.component.ts @@ -1,3 +1,4 @@ +import { Directive, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { KeysRequest } from 'jslib-common/models/request/keysRequest'; @@ -7,6 +8,7 @@ import { RegisterRequest } from 'jslib-common/models/request/registerRequest'; import { ApiService } from 'jslib-common/abstractions/api.service'; import { AuthService } from 'jslib-common/abstractions/auth.service'; import { CryptoService } from 'jslib-common/abstractions/crypto.service'; +import { EnvironmentService } from 'jslib-common/abstractions/environment.service'; import { I18nService } from 'jslib-common/abstractions/i18n.service'; import { PasswordGenerationService } from 'jslib-common/abstractions/passwordGeneration.service'; import { PlatformUtilsService } from 'jslib-common/abstractions/platformUtils.service'; @@ -14,7 +16,10 @@ import { StateService } from 'jslib-common/abstractions/state.service'; import { KdfType } from 'jslib-common/enums/kdfType'; -export class RegisterComponent { +import { CaptchaProtectedComponent } from './captchaProtected.component'; + +@Directive() +export class RegisterComponent extends CaptchaProtectedComponent implements OnInit { name: string = ''; email: string = ''; masterPassword: string = ''; @@ -31,13 +36,18 @@ export class RegisterComponent { private masterPasswordStrengthTimeout: any; constructor(protected authService: AuthService, protected router: Router, - protected i18nService: I18nService, protected cryptoService: CryptoService, + i18nService: I18nService, protected cryptoService: CryptoService, protected apiService: ApiService, protected stateService: StateService, - protected platformUtilsService: PlatformUtilsService, - protected passwordGenerationService: PasswordGenerationService) { + platformUtilsService: PlatformUtilsService, + protected passwordGenerationService: PasswordGenerationService, environmentService: EnvironmentService) { + super(environmentService, i18nService, platformUtilsService); this.showTerms = !platformUtilsService.isSelfHost(); } + async ngOnInit() { + this.setupCaptcha(); + } + get masterPasswordScoreWidth() { return this.masterPasswordScore == null ? 0 : (this.masterPasswordScore + 1) * 20; } @@ -127,7 +137,7 @@ export class RegisterComponent { const hashedPassword = await this.cryptoService.hashPassword(this.masterPassword, key); const keys = await this.cryptoService.makeKeyPair(encKey[0]); const request = new RegisterRequest(this.email, this.name, hashedPassword, - this.hint, encKey[1].encryptedString, kdf, kdfIterations, this.referenceData); + this.hint, encKey[1].encryptedString, kdf, kdfIterations, this.referenceData, this.captchaToken); request.keys = new KeysRequest(keys[0], keys[1].encryptedString); const orgInvite = await this.stateService.get('orgInvitation'); if (orgInvite != null && orgInvite.token != null && orgInvite.organizationUserId != null) { @@ -137,7 +147,15 @@ export class RegisterComponent { try { this.formPromise = this.apiService.postRegister(request); - await this.formPromise; + try { + await this.formPromise; + } catch (e) { + if (this.handleCaptchaRequired(e)) { + return; + } else { + throw e; + } + } this.platformUtilsService.showToast('success', null, this.i18nService.t('newAccountCreated')); this.router.navigate([this.successRoute], { queryParams: { email: this.email } }); } catch { } diff --git a/angular/src/directives/api-action.directive.ts b/angular/src/directives/api-action.directive.ts index a04eb479473..f6118f7b7f7 100644 --- a/angular/src/directives/api-action.directive.ts +++ b/angular/src/directives/api-action.directive.ts @@ -4,6 +4,9 @@ import { Input, OnChanges, } from '@angular/core'; +import { LogService } from 'jslib-common/abstractions/log.service'; + +import { ErrorResponse } from 'jslib-common/models/response'; import { ValidationService } from '../services/validation.service'; @@ -13,7 +16,8 @@ import { ValidationService } from '../services/validation.service'; export class ApiActionDirective implements OnChanges { @Input() appApiAction: Promise; - constructor(private el: ElementRef, private validationService: ValidationService) { } + constructor(private el: ElementRef, private validationService: ValidationService, + private logService: LogService) { } ngOnChanges(changes: any) { if (this.appApiAction == null || this.appApiAction.then == null) { @@ -26,6 +30,11 @@ export class ApiActionDirective implements OnChanges { this.el.nativeElement.loading = false; }, (e: any) => { this.el.nativeElement.loading = false; + + if (e instanceof ErrorResponse && (e as ErrorResponse).captchaRequired) { + this.logService.error('Captcha required error response: ' + e.getSingleMessage()); + return; + } this.validationService.showError(e); }); } diff --git a/common/src/models/request/captchaProtectedRequest.ts b/common/src/models/request/captchaProtectedRequest.ts new file mode 100644 index 00000000000..18b6d6c2297 --- /dev/null +++ b/common/src/models/request/captchaProtectedRequest.ts @@ -0,0 +1,3 @@ +export abstract class CaptchaProtectedRequest { + captchaResponse: string = null; +} diff --git a/common/src/models/request/registerRequest.ts b/common/src/models/request/registerRequest.ts index a93ac69bba8..04f06b49077 100644 --- a/common/src/models/request/registerRequest.ts +++ b/common/src/models/request/registerRequest.ts @@ -3,28 +3,18 @@ import { ReferenceEventRequest } from './referenceEventRequest'; import { KdfType } from '../../enums/kdfType'; -export class RegisterRequest { - name: string; - email: string; - masterPasswordHash: string; +import { CaptchaProtectedRequest } from './captchaProtectedRequest'; + +export class RegisterRequest implements CaptchaProtectedRequest { masterPasswordHint: string; - key: string; keys: KeysRequest; token: string; organizationUserId: string; - kdf: KdfType; - kdfIterations: number; - referenceData: ReferenceEventRequest; - constructor(email: string, name: string, masterPasswordHash: string, masterPasswordHint: string, key: string, - kdf: KdfType, kdfIterations: number, referenceData: ReferenceEventRequest) { - this.name = name; - this.email = email; - this.masterPasswordHash = masterPasswordHash; + + constructor(public email: string, public name: string, public masterPasswordHash: string, + masterPasswordHint: string, public key: string, public kdf: KdfType, public kdfIterations: number, + public referenceData: ReferenceEventRequest, public captchaResponse: string) { this.masterPasswordHint = masterPasswordHint ? masterPasswordHint : null; - this.key = key; - this.kdf = kdf; - this.kdfIterations = kdfIterations; - this.referenceData = referenceData; } } diff --git a/common/src/models/request/tokenRequest.ts b/common/src/models/request/tokenRequest.ts index 3cad488aee6..0797fe8fa40 100644 --- a/common/src/models/request/tokenRequest.ts +++ b/common/src/models/request/tokenRequest.ts @@ -1,8 +1,9 @@ import { TwoFactorProviderType } from '../../enums/twoFactorProviderType'; +import { CaptchaProtectedRequest } from './captchaProtectedRequest'; import { DeviceRequest } from './deviceRequest'; -export class TokenRequest { +export class TokenRequest implements CaptchaProtectedRequest { email: string; masterPasswordHash: string; code: string; @@ -10,14 +11,10 @@ export class TokenRequest { redirectUri: string; clientId: string; clientSecret: string; - token: string; - provider: TwoFactorProviderType; - remember: boolean; - captchaToken: string; device?: DeviceRequest; - constructor(credentials: string[], codes: string[], clientIdClientSecret: string[], provider: TwoFactorProviderType, - token: string, remember: boolean, captchaToken: string, device?: DeviceRequest) { + constructor(credentials: string[], codes: string[], clientIdClientSecret: string[], public provider: TwoFactorProviderType, + public token: string, public remember: boolean, public captchaResponse: string, device?: DeviceRequest) { if (credentials != null && credentials.length > 1) { this.email = credentials[0]; this.masterPasswordHash = credentials[1]; @@ -29,11 +26,7 @@ export class TokenRequest { this.clientId = clientIdClientSecret[0]; this.clientSecret = clientIdClientSecret[1]; } - this.token = token; - this.provider = provider; - this.remember = remember; this.device = device != null ? device : null; - this.captchaToken = captchaToken; } toIdentityToken(clientId: string) { @@ -73,8 +66,8 @@ export class TokenRequest { obj.twoFactorRemember = this.remember ? '1' : '0'; } - if (this.captchaToken != null) { - obj.captchaResponse = this.captchaToken; + if (this.captchaResponse != null) { + obj.captchaResponse = this.captchaResponse; } diff --git a/common/src/models/response/errorResponse.ts b/common/src/models/response/errorResponse.ts index fcce9f93d48..bac1ff12120 100644 --- a/common/src/models/response/errorResponse.ts +++ b/common/src/models/response/errorResponse.ts @@ -1,9 +1,13 @@ +import { Utils } from '../../misc/utils'; + import { BaseResponse } from './baseResponse'; export class ErrorResponse extends BaseResponse { message: string; validationErrors: { [key: string]: string[]; }; statusCode: number; + captchaRequired: boolean; + captchaSiteKey: string; constructor(response: any, status: number, identityResponse?: boolean) { super(response); @@ -20,6 +24,8 @@ export class ErrorResponse extends BaseResponse { if (errorModel) { this.message = this.getResponseProperty('Message', errorModel); this.validationErrors = this.getResponseProperty('ValidationErrors', errorModel); + this.captchaSiteKey = this.validationErrors?.HCaptcha_SiteKey?.[0]; + this.captchaRequired = !Utils.isNullOrWhitespace(this.captchaSiteKey); } else { if (status === 429) { this.message = 'Rate limit exceeded. Try again later.';