[PM-7084] 6/6: Introduce shared duo two-factor component (#9772)

* Add shared duo component

* Fix duo import

* Fix wrong i18n service DI in duo desktop component

* Remove duo v2

* Add override to functions

* Remove web duo implementation

* Update apps/browser/src/auth/popup/two-factor-auth-duo.component.ts

Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com>

* Update apps/desktop/src/auth/two-factor-auth-duo.component.ts

Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com>

* Update libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.ts

Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com>

* Fix missing service on duo components

* Fix missing service on base duo auth component

* Fix constructor super calls in duo auth component

* Fix duo auth components incorrectly extending base class

---------

Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com>
This commit is contained in:
Bernd Schoolmann 2024-07-19 16:29:24 +02:00 committed by GitHub
parent bc1ee0a169
commit 05e8b45edb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 406 additions and 2 deletions

View File

@ -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<void>();
duoResultSubscription: Subscription;
constructor(
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
private browserMessagingApi: ZonedMessageListenerService,
private environmentService: EnvironmentService,
toastService: ToastService,
) {
super(i18nService, platformUtilsService, toastService);
}
async ngOnInit(): Promise<void> {
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);
}
}

View File

@ -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],

View File

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

View File

@ -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],

View File

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

View File

@ -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],

View File

@ -0,0 +1,6 @@
<ng-container>
<p bitTypography="body1" class="tw-mb-0">
{{ "duoRequiredByOrgForAccount" | i18n }}
</p>
<p bitTypography="body1">{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}</p>
</ng-container>

View File

@ -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<string>();
@Input() providerData: any;
duoFramelessUrl: string = null;
duoResultListenerInitialized = false;
constructor(
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected toastService: ToastService,
) {}
async ngOnInit(): Promise<void> {
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<void> {
if (this.duoFramelessUrl === null) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("duoHealthCheckResultsInNullAuthUrlError"),
});
return;
}
this.platformUtilsService.launchUri(this.duoFramelessUrl);
}
}

View File

@ -16,6 +16,15 @@
(token)="token = $event; submitForm()"
*ngIf="selectedProviderType === providerType.WebAuthn"
/>
<app-two-factor-auth-duo
(token)="token = $event; submitForm()"
[providerData]="providerData"
*ngIf="
selectedProviderType === providerType.OrganizationDuo ||
selectedProviderType === providerType.Duo
"
#duoComponent
/>
<bit-form-control *ngIf="selectedProviderType != null">
<bit-label>{{ "rememberMe" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="remember" />
@ -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
"
>
<span> <i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ actionButtonText }} </span>
</button>
<button
type="button"
buttonType="primary"
bitButton
(click)="launchDuo()"
*ngIf="
selectedProviderType === providerType.Duo ||
selectedProviderType === providerType.OrganizationDuo
"
>
<span> <i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ "launchDuo" | i18n }}</span>
</button>
<a routerLink="/login" bitButton buttonType="secondary">
{{ "cancel" | i18n }}
</a>

View File

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