diff --git a/apps/browser/src/auth/popup/two-factor-auth-duo.component.ts b/apps/browser/src/auth/popup/two-factor-auth-duo.component.ts new file mode 100644 index 0000000000..af1d0d7767 --- /dev/null +++ b/apps/browser/src/auth/popup/two-factor-auth-duo.component.ts @@ -0,0 +1,105 @@ +import { DialogModule } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { ReactiveFormsModule, FormsModule } from "@angular/forms"; +import { Subject, Subscription, filter, firstValueFrom, takeUntil } from "rxjs"; + +import { TwoFactorAuthDuoComponent as TwoFactorAuthDuoBaseComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-duo.component"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ToastService } from "@bitwarden/components"; + +import { AsyncActionsModule } from "../../../../../libs/components/src/async-actions"; +import { ButtonModule } from "../../../../../libs/components/src/button"; +import { FormFieldModule } from "../../../../../libs/components/src/form-field"; +import { LinkModule } from "../../../../../libs/components/src/link"; +import { I18nPipe } from "../../../../../libs/components/src/shared/i18n.pipe"; +import { TypographyModule } from "../../../../../libs/components/src/typography"; +import { ZonedMessageListenerService } from "../../platform/browser/zoned-message-listener.service"; + +@Component({ + standalone: true, + selector: "app-two-factor-auth-duo", + templateUrl: + "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.html", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + LinkModule, + TypographyModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + FormsModule, + ], + providers: [I18nPipe], +}) +export class TwoFactorAuthDuoComponent extends TwoFactorAuthDuoBaseComponent { + private destroy$ = new Subject(); + duoResultSubscription: Subscription; + + constructor( + protected i18nService: I18nService, + protected platformUtilsService: PlatformUtilsService, + private browserMessagingApi: ZonedMessageListenerService, + private environmentService: EnvironmentService, + toastService: ToastService, + ) { + super(i18nService, platformUtilsService, toastService); + } + + async ngOnInit(): Promise { + await super.ngOnInit(); + } + + async ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + protected override setupDuoResultListener() { + if (!this.duoResultSubscription) { + this.duoResultSubscription = this.browserMessagingApi + .messageListener$() + .pipe( + filter((msg: any) => msg.command === "duoResult"), + takeUntil(this.destroy$), + ) + .subscribe((msg: { command: string; code: string; state: string }) => { + this.token.emit(msg.code + "|" + msg.state); + }); + } + } + + override async launchDuoFrameless() { + if (this.duoFramelessUrl === null) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("duoHealthCheckResultsInNullAuthUrlError"), + }); + return; + } + const duoHandOffMessage = { + title: this.i18nService.t("youSuccessfullyLoggedIn"), + message: this.i18nService.t("youMayCloseThisWindow"), + isCountdown: false, + }; + + // we're using the connector here as a way to set a cookie with translations + // before continuing to the duo frameless url + const env = await firstValueFrom(this.environmentService.environment$); + const launchUrl = + env.getWebVaultUrl() + + "/duo-redirect-connector.html" + + "?duoFramelessUrl=" + + encodeURIComponent(this.duoFramelessUrl) + + "&handOffMessage=" + + encodeURIComponent(JSON.stringify(duoHandOffMessage)); + this.platformUtilsService.launchUri(launchUrl); + } +} diff --git a/apps/browser/src/auth/popup/two-factor-auth.component.ts b/apps/browser/src/auth/popup/two-factor-auth.component.ts index d2a1ba20bf..9ae1f08825 100644 --- a/apps/browser/src/auth/popup/two-factor-auth.component.ts +++ b/apps/browser/src/auth/popup/two-factor-auth.component.ts @@ -42,6 +42,7 @@ import { import { BrowserApi } from "../../platform/browser/browser-api"; import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; +import { TwoFactorAuthDuoComponent } from "./two-factor-auth-duo.component"; import { TwoFactorAuthEmailComponent } from "./two-factor-auth-email.component"; @Component({ @@ -65,6 +66,7 @@ import { TwoFactorAuthEmailComponent } from "./two-factor-auth-email.component"; TwoFactorAuthEmailComponent, TwoFactorAuthAuthenticatorComponent, TwoFactorAuthYubikeyComponent, + TwoFactorAuthDuoComponent, TwoFactorAuthWebAuthnComponent, ], providers: [I18nPipe], diff --git a/apps/desktop/src/auth/two-factor-auth-duo.component.ts b/apps/desktop/src/auth/two-factor-auth-duo.component.ts new file mode 100644 index 0000000000..804afccee4 --- /dev/null +++ b/apps/desktop/src/auth/two-factor-auth-duo.component.ts @@ -0,0 +1,110 @@ +import { DialogModule } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, NgZone } from "@angular/core"; +import { ReactiveFormsModule, FormsModule } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + LinkModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import { TwoFactorAuthDuoComponent as TwoFactorAuthDuoBaseComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component"; + +const BroadcasterSubscriptionId = "TwoFactorComponent"; + +@Component({ + standalone: true, + selector: "app-two-factor-auth-duo", + templateUrl: + "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.html", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + LinkModule, + TypographyModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + FormsModule, + ], + providers: [I18nPipe], +}) +export class TwoFactorAuthDuoComponent extends TwoFactorAuthDuoBaseComponent { + constructor( + protected i18nService: I18nService, + protected platformUtilsService: PlatformUtilsService, + private broadcasterService: BroadcasterService, + private ngZone: NgZone, + private environmentService: EnvironmentService, + toastService: ToastService, + ) { + super(i18nService, platformUtilsService, toastService); + } + + async ngOnInit(): Promise { + await super.ngOnInit(); + } + + duoCallbackSubscriptionEnabled: boolean = false; + + protected override setupDuoResultListener() { + if (!this.duoCallbackSubscriptionEnabled) { + this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { + await this.ngZone.run(async () => { + if (message.command === "duoCallback") { + this.token.emit(message.code + "|" + message.state); + } + }); + }); + this.duoCallbackSubscriptionEnabled = true; + } + } + + override async launchDuoFrameless() { + if (this.duoFramelessUrl === null) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("duoHealthCheckResultsInNullAuthUrlError"), + }); + return; + } + const duoHandOffMessage = { + title: this.i18nService.t("youSuccessfullyLoggedIn"), + message: this.i18nService.t("youMayCloseThisWindow"), + isCountdown: false, + }; + + // we're using the connector here as a way to set a cookie with translations + // before continuing to the duo frameless url + const env = await firstValueFrom(this.environmentService.environment$); + const launchUrl = + env.getWebVaultUrl() + + "/duo-redirect-connector.html" + + "?duoFramelessUrl=" + + encodeURIComponent(this.duoFramelessUrl) + + "&handOffMessage=" + + encodeURIComponent(JSON.stringify(duoHandOffMessage)); + this.platformUtilsService.launchUri(launchUrl); + } + + async ngOnDestroy() { + if (this.duoCallbackSubscriptionEnabled) { + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + this.duoCallbackSubscriptionEnabled = false; + } + } +} diff --git a/apps/desktop/src/auth/two-factor-auth.component.ts b/apps/desktop/src/auth/two-factor-auth.component.ts index bb1ef60138..29271b565c 100644 --- a/apps/desktop/src/auth/two-factor-auth.component.ts +++ b/apps/desktop/src/auth/two-factor-auth.component.ts @@ -19,6 +19,8 @@ import { LinkModule } from "../../../../libs/components/src/link"; import { I18nPipe } from "../../../../libs/components/src/shared/i18n.pipe"; import { TypographyModule } from "../../../../libs/components/src/typography"; +import { TwoFactorAuthDuoComponent } from "./two-factor-auth-duo.component"; + @Component({ standalone: true, templateUrl: @@ -40,6 +42,7 @@ import { TypographyModule } from "../../../../libs/components/src/typography"; TwoFactorAuthEmailComponent, TwoFactorAuthAuthenticatorComponent, TwoFactorAuthYubikeyComponent, + TwoFactorAuthDuoComponent, TwoFactorAuthWebAuthnComponent, ], providers: [I18nPipe], diff --git a/apps/web/src/app/auth/two-factor-auth-duo.component.ts b/apps/web/src/app/auth/two-factor-auth-duo.component.ts new file mode 100644 index 0000000000..0163f8474d --- /dev/null +++ b/apps/web/src/app/auth/two-factor-auth-duo.component.ts @@ -0,0 +1,60 @@ +import { DialogModule } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { ReactiveFormsModule, FormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; + +import { TwoFactorAuthDuoComponent as TwoFactorAuthDuoBaseComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component"; +import { AsyncActionsModule } from "../../../../../libs/components/src/async-actions"; +import { ButtonModule } from "../../../../../libs/components/src/button"; +import { FormFieldModule } from "../../../../../libs/components/src/form-field"; +import { LinkModule } from "../../../../../libs/components/src/link"; +import { TypographyModule } from "../../../../../libs/components/src/typography"; + +@Component({ + standalone: true, + selector: "app-two-factor-auth-duo", + templateUrl: + "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.html", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + LinkModule, + TypographyModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + FormsModule, + ], + providers: [I18nPipe], +}) +export class TwoFactorAuthDuoComponent extends TwoFactorAuthDuoBaseComponent { + async ngOnInit(): Promise { + await super.ngOnInit(); + } + + private duoResultChannel: BroadcastChannel; + + protected override setupDuoResultListener() { + if (!this.duoResultChannel) { + this.duoResultChannel = new BroadcastChannel("duoResult"); + this.duoResultChannel.addEventListener("message", this.handleDuoResultMessage); + } + } + + private handleDuoResultMessage = async (msg: { data: { code: string; state: string } }) => { + this.token.emit(msg.data.code + "|" + msg.data.state); + }; + + async ngOnDestroy() { + if (this.duoResultChannel) { + // clean up duo listener if it was initialized. + this.duoResultChannel.removeEventListener("message", this.handleDuoResultMessage); + this.duoResultChannel.close(); + } + } +} diff --git a/apps/web/src/app/auth/two-factor-auth.component.ts b/apps/web/src/app/auth/two-factor-auth.component.ts index 352d935728..fbdddecce9 100644 --- a/apps/web/src/app/auth/two-factor-auth.component.ts +++ b/apps/web/src/app/auth/two-factor-auth.component.ts @@ -34,6 +34,8 @@ import { AsyncActionsModule } from "../../../../../libs/components/src/async-act import { ButtonModule } from "../../../../../libs/components/src/button"; import { FormFieldModule } from "../../../../../libs/components/src/form-field"; +import { TwoFactorAuthDuoComponent } from "./two-factor-auth-duo.component"; + @Component({ standalone: true, templateUrl: @@ -55,6 +57,7 @@ import { FormFieldModule } from "../../../../../libs/components/src/form-field"; TwoFactorAuthEmailComponent, TwoFactorAuthAuthenticatorComponent, TwoFactorAuthYubikeyComponent, + TwoFactorAuthDuoComponent, TwoFactorAuthWebAuthnComponent, ], providers: [I18nPipe], diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.html new file mode 100644 index 0000000000..34b7ee9039 --- /dev/null +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.html @@ -0,0 +1,6 @@ + +

+ {{ "duoRequiredByOrgForAccount" | i18n }} +

+

{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}

+
diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.ts b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.ts new file mode 100644 index 0000000000..1d6b3e2629 --- /dev/null +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.ts @@ -0,0 +1,79 @@ +import { DialogModule } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { ReactiveFormsModule, FormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + ButtonModule, + LinkModule, + TypographyModule, + FormFieldModule, + AsyncActionsModule, + ToastService, +} from "@bitwarden/components"; + +@Component({ + standalone: true, + selector: "app-two-factor-auth-duo", + templateUrl: "two-factor-auth-duo.component.html", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + LinkModule, + TypographyModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + FormsModule, + ], + providers: [I18nPipe], +}) +export class TwoFactorAuthDuoComponent { + @Output() token = new EventEmitter(); + @Input() providerData: any; + + duoFramelessUrl: string = null; + duoResultListenerInitialized = false; + + constructor( + protected i18nService: I18nService, + protected platformUtilsService: PlatformUtilsService, + protected toastService: ToastService, + ) {} + + async ngOnInit(): Promise { + await this.init(); + } + + async init() { + // Setup listener for duo-redirect.ts connector to send back the code + if (!this.duoResultListenerInitialized) { + // setup client specific duo result listener + this.setupDuoResultListener(); + this.duoResultListenerInitialized = true; + } + + // flow must be launched by user so they can choose to remember the device or not. + this.duoFramelessUrl = this.providerData.AuthUrl; + } + + // Each client will have own implementation + protected setupDuoResultListener(): void {} + async launchDuoFrameless(): Promise { + if (this.duoFramelessUrl === null) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("duoHealthCheckResultsInNullAuthUrlError"), + }); + return; + } + this.platformUtilsService.launchUri(this.duoFramelessUrl); + } +} diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html index 33a5e291fa..137a4f903b 100644 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html @@ -16,6 +16,15 @@ (token)="token = $event; submitForm()" *ngIf="selectedProviderType === providerType.WebAuthn" /> + {{ "rememberMe" | i18n }} @@ -35,10 +44,28 @@ buttonType="primary" bitButton bitFormButton - *ngIf="selectedProviderType != null && selectedProviderType !== providerType.WebAuthn" + *ngIf=" + selectedProviderType != null && + selectedProviderType !== providerType.WebAuthn && + selectedProviderType !== providerType.Duo && + selectedProviderType !== providerType.OrganizationDuo + " > {{ actionButtonText }} + + {{ "cancel" | i18n }} diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts index 16a95d6ba2..21aaf119c4 100644 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { Component, Inject, OnInit } from "@angular/core"; +import { Component, Inject, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { ActivatedRoute, NavigationExtras, Router, RouterLink } from "@angular/router"; import { Subject, takeUntil, lastValueFrom, first, firstValueFrom } from "rxjs"; @@ -39,6 +39,7 @@ import { import { CaptchaProtectedComponent } from "../captcha-protected.component"; import { TwoFactorAuthAuthenticatorComponent } from "./two-factor-auth-authenticator.component"; +import { TwoFactorAuthDuoComponent } from "./two-factor-auth-duo.component"; import { TwoFactorAuthEmailComponent } from "./two-factor-auth-email.component"; import { TwoFactorAuthWebAuthnComponent } from "./two-factor-auth-webauthn.component"; import { TwoFactorAuthYubikeyComponent } from "./two-factor-auth-yubikey.component"; @@ -63,6 +64,7 @@ import { TwoFactorOptionsComponent, TwoFactorAuthAuthenticatorComponent, TwoFactorAuthEmailComponent, + TwoFactorAuthDuoComponent, TwoFactorAuthYubikeyComponent, TwoFactorAuthWebAuthnComponent, ], @@ -78,6 +80,7 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements selectedProviderType: TwoFactorProviderType = TwoFactorProviderType.Authenticator; providerData: any; + @ViewChild("duoComponent") duoComponent!: TwoFactorAuthDuoComponent; formGroup = this.formBuilder.group({ token: [ "", @@ -220,6 +223,12 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements } } + async launchDuo() { + if (this.duoComponent != null) { + await this.duoComponent.launchDuoFrameless(); + } + } + protected handleMigrateEncryptionKey(result: AuthResult): boolean { if (!result.requiresEncryptionKeyMigration) { return false;