diff --git a/src/angular/components/two-factor.component.ts b/src/angular/components/two-factor.component.ts index 2005bcac49..eaba393d5b 100644 --- a/src/angular/components/two-factor.component.ts +++ b/src/angular/components/two-factor.component.ts @@ -1,4 +1,7 @@ -import { OnInit } from '@angular/core'; +import { + OnDestroy, + OnInit, +} from '@angular/core'; import { Router } from '@angular/router'; import { ToasterService } from 'angular2-toaster'; @@ -12,13 +15,16 @@ import { TwoFactorEmailRequest } from '../../models/request/twoFactorEmailReques import { ApiService } from '../../abstractions/api.service'; import { AuthService } from '../../abstractions/auth.service'; +import { EnvironmentService } from '../../abstractions/environment.service'; import { I18nService } from '../../abstractions/i18n.service'; import { PlatformUtilsService } from '../../abstractions/platformUtils.service'; import { SyncService } from '../../abstractions/sync.service'; import { TwoFactorProviders } from '../../services/auth.service'; -export class TwoFactorComponent implements OnInit { +import { U2f } from '../../misc/u2f'; + +export class TwoFactorComponent implements OnInit, OnDestroy { token: string = ''; remember: boolean = false; u2fReady: boolean = false; @@ -26,7 +32,7 @@ export class TwoFactorComponent implements OnInit { providerType = TwoFactorProviderType; selectedProviderType: TwoFactorProviderType = TwoFactorProviderType.Authenticator; u2fSupported: boolean = false; - u2f: any = null; + u2f: U2f = null; title: string = ''; twoFactorEmail: string = null; formPromise: Promise; @@ -37,7 +43,8 @@ export class TwoFactorComponent implements OnInit { constructor(protected authService: AuthService, protected router: Router, protected analytics: Angulartics2, protected toasterService: ToasterService, protected i18nService: I18nService, protected apiService: ApiService, - protected platformUtilsService: PlatformUtilsService, protected syncService: SyncService) { + protected platformUtilsService: PlatformUtilsService, protected syncService: SyncService, + protected win: Window, protected environmentService: EnvironmentService) { this.u2fSupported = this.platformUtilsService.supportsU2f(window); } @@ -48,26 +55,62 @@ export class TwoFactorComponent implements OnInit { return; } + if (this.win != null && this.u2fSupported) { + let customWebVaultUrl: string = null; + if (this.environmentService.baseUrl) { + customWebVaultUrl = this.environmentService.baseUrl; + } + else if (this.environmentService.webVaultUrl) { + customWebVaultUrl = this.environmentService.webVaultUrl; + } + + this.u2f = new U2f(this.win, customWebVaultUrl, (token: string) => { + this.token = token; + this.submit(); + }, (error: string) => { + this.toasterService.popAsync('error', this.i18nService.t('errorOccurred'), error); + }, (info: string) => { + if (info === 'ready') { + this.u2fReady = true; + } + }); + } + this.selectedProviderType = this.authService.getDefaultTwoFactorProvider(this.u2fSupported); await this.init(); } + ngOnDestroy(): void { + this.cleanupU2f(); + this.u2f = null; + } + async init() { if (this.selectedProviderType == null) { this.title = this.i18nService.t('loginUnavailable'); return; } + this.cleanupU2f(); this.title = (TwoFactorProviders as any)[this.selectedProviderType].name; const params = this.authService.twoFactorProviders.get(this.selectedProviderType); switch (this.selectedProviderType) { case TwoFactorProviderType.U2f: - if (!this.u2fSupported) { + if (!this.u2fSupported || this.u2f == null) { break; } const challenges = JSON.parse(params.Challenges); - // TODO: init u2f + if (challenges.length > 0) { + this.u2f.init({ + appId: challenges[0].appId, + challenge: challenges[0].challenge, + keys: [{ + version: challenges[0].version, + keyHandle: challenges[0].keyHandle + }], + }); + } break; case TwoFactorProviderType.Duo: case TwoFactorProviderType.OrganizationDuo: @@ -104,7 +147,11 @@ export class TwoFactorComponent implements OnInit { } if (this.selectedProviderType === TwoFactorProviderType.U2f) { - // TODO: stop U2f + if (this.u2f != null) { + this.u2f.stop(); + } else { + return; + } } else if (this.selectedProviderType === TwoFactorProviderType.Email || this.selectedProviderType === TwoFactorProviderType.Authenticator) { this.token = this.token.replace(' ', '').trim(); @@ -116,9 +163,11 @@ export class TwoFactorComponent implements OnInit { this.syncService.fullSync(true); this.analytics.eventTrack.next({ action: 'Logged In From Two-step' }); this.router.navigate([this.successRoute]); - } catch { - if (this.selectedProviderType === TwoFactorProviderType.U2f) { - // TODO: start U2F again + } catch (e) { + if (this.selectedProviderType === TwoFactorProviderType.U2f && this.u2f != null) { + this.u2f.start(); + } else { + throw e; } } } @@ -144,4 +193,11 @@ export class TwoFactorComponent implements OnInit { this.emailPromise = null; } + + private cleanupU2f() { + if (this.u2f != null) { + this.u2f.stop(); + this.u2f.cleanup(); + } + } } diff --git a/src/misc/u2f.ts b/src/misc/u2f.ts new file mode 100644 index 0000000000..cb1696c62f --- /dev/null +++ b/src/misc/u2f.ts @@ -0,0 +1,73 @@ +export class U2f { + private iframe: HTMLIFrameElement = null; + private connectorLink: HTMLAnchorElement; + + constructor(private win: Window, private webVaultUrl: string, private successCallback: Function, + private errorCallback: Function, private infoCallback: Function) { + this.connectorLink = win.document.createElement('a'); + this.webVaultUrl = webVaultUrl != null && webVaultUrl !== '' ? webVaultUrl : 'https://vault.bitwarden.com'; + } + + init(data: any): void { + this.connectorLink.href = this.webVaultUrl + '/u2f-connector.html' + + '?data=' + this.base64Encode(JSON.stringify(data)) + + '&parent=' + encodeURIComponent(this.win.document.location.href) + + '&v=1'; + + this.iframe = this.win.document.getElementById('u2f_iframe') as HTMLIFrameElement; + this.iframe.src = this.connectorLink.href; + + this.win.addEventListener('message', (e) => this.parseMessage(e), false); + } + + stop() { + this.sendMessage('stop'); + } + + start() { + this.sendMessage('start'); + } + + sendMessage(message: any) { + if (!this.iframe || !this.iframe.src || !this.iframe.contentWindow) { + return; + } + + this.iframe.contentWindow.postMessage(message, this.iframe.src); + } + + base64Encode(str: string): string { + return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => { + return String.fromCharCode(('0x' + p1) as any); + })); + } + + cleanup() { + this.win.removeEventListener('message', (e) => this.parseMessage(e), false); + } + + private parseMessage(event: any) { + if (!this.validMessage(event)) { + this.errorCallback('Invalid message.'); + return; + } + + const parts: string[] = event.data.split('|'); + if (parts[0] === 'success' && this.successCallback) { + this.successCallback(parts[1]); + } else if (parts[0] === 'error' && this.errorCallback) { + this.errorCallback(parts[1]); + } else if (parts[0] === 'info' && this.infoCallback) { + this.infoCallback(parts[1]); + } + } + + private validMessage(event: any) { + if (!event.origin || event.origin === '' || event.origin !== (this.connectorLink as any).origin) { + return false; + } + + return event.data.indexOf('success|') === 0 || event.data.indexOf('error|') === 0 || + event.data.indexOf('info|') === 0; + } +}