1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-06-25 10:25:36 +02:00

Auth/PM-5368 & PM-4613 - Web & Browser - Add support for new 2FA Duo Frameless Redirect flow (#7670)

* [PM-5368] Open Duo auth url. Add BroadcastChannel listener for duo result.

* [PM-5368] Remove debug line. Use PlatformUtilService to launch Uri.

* PM-5368 - Some progress on getting new frameless duo implementation in place

* PM-5368 - Base2FAComp - Save off duoFramelessUrl for use later on as user must be given the option to remember the device before launching the duo frameless flow in the new tab.

* PM-5368 - Web - 2FA Comp - (1) Only show larger width when showing backwards compatible duo (2) Stack buttons per new design (3) selectedProviderType === providerType.OrganizationDuo is correct check for when org requires DUO

* PM-5368 - Web - 2FA Comp - translate duo stuff

* PM-4613 - Browser 2FA - Get most of DUO frameless in place. WIP. Must figure out how to transfer state from popup to popout + add popout logic to auth-popout-windows.ts. Converted existing useAnotherTwoStepMethod button to use new comp lib bitButton per design.

* PM-4613 - Browser 2FA Comp - (1) HTML - add margin around duo frameless text to match figma (2) Get popout extension logic working properly - now closes existing popup

* PM-4613 - TODO figure out communication between web and browser as broadcast channel will not work.

* PM-5368 - Base comp + web changes - (1)  Base component now has a setupDuoResultListener method for child classes to override (2) Web overrides setupDuoResultListener and cleans up broadcast channel once a duo result comes through.

* PM-4613 - Browser - (1) Add window message handling to content-message-handler content script to pass along the duo result message to the browser extension (2) 2FA comp - override setupDuoResultListener and use browserMessagingApi to listen to duoResult and submit when it comes through.

* PM-5368 - Web - 2FA comp - only clean up duo result channel on ngDestroy so that user can re-submit if an error occurs.

* PM-5368 and PM-4613 - (1) Update base 2FA comp to only initialize duo result listener once as init is called any time the user changes 2FA option if multiple are present (duo org and duo personal) (2) Each client now will only create a listener once even if it is called more than once (3) On web, only try to clean up the duoResultChannel if it was created to avoid erroring on other 2FA methods.

* PM-5368 - Base 2FA comp - add TODO to remove duo SDK handling once we remove the duo-redirect flag

* PM-5368 - Per PR feedback, avoid repetition of duo provider check by using a new public property for isDuoProvider

* PM-4613 -  Per PR feedback: (1) Deconstruct code out of data (2) Add test for duoResult.

---------

Co-authored-by: André Bispo <abispo@bitwarden.com>
This commit is contained in:
Jared Snider 2024-02-05 13:23:50 -05:00 committed by GitHub
parent 414ee2563f
commit c91ceb2014
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 282 additions and 60 deletions

View File

@ -2695,6 +2695,21 @@
}
}
},
"launchDuoAndFollowStepsToFinishLoggingIn": {
"message": "Launch DUO and follow the steps to finish logging in."
},
"duoRequiredByOrgForAccount": {
"message": "DUO two-step login is required for your account."
},
"openExtensionInNewWindowToCompleteLogin": {
"message": "Open the extension in a new window to complete login"
},
"popoutExtension": {
"message": "Popout extension"
},
"launchDuo": {
"message": "Launch DUO"
},
"importFormatError": {
"message": "Data is not formatted correctly. Please check your import file and try again."
},

View File

@ -12,8 +12,7 @@
[disabled]="form.loading"
*ngIf="
selectedProviderType != null &&
selectedProviderType !== providerType.Duo &&
selectedProviderType !== providerType.OrganizationDuo &&
!isDuoProvider &&
(selectedProviderType !== providerType.WebAuthn || form.loading)
"
>
@ -23,6 +22,7 @@
</div>
</header>
<main tabindex="-1">
<!-- Authenticator / Email -->
<ng-container
*ngIf="
selectedProviderType === providerType.Authenticator ||
@ -59,6 +59,7 @@
</div>
</div>
</ng-container>
<!-- YubiKey -->
<ng-container *ngIf="selectedProviderType === providerType.Yubikey">
<div class="content text-center">
<p class="text-center">{{ "insertYubiKey" | i18n }}</p>
@ -85,6 +86,7 @@
</div>
</div>
</ng-container>
<!-- WebAuthN (not-webAuthN tab) -->
<ng-container *ngIf="selectedProviderType === providerType.WebAuthn && !webAuthnNewTab">
<div id="web-authn-frame">
<iframe id="webauthn_iframe" sandbox="allow-scripts allow-same-origin"></iframe>
@ -98,6 +100,7 @@
</div>
</div>
</ng-container>
<!-- WebAuthN (webAuthN tab) -->
<ng-container *ngIf="selectedProviderType === providerType.WebAuthn && webAuthnNewTab">
<div class="content text-center" *ngIf="webAuthnNewTab">
<p class="text-center">{{ "webAuthnNewTab" | i18n }}</p>
@ -106,26 +109,48 @@
</button>
</div>
</ng-container>
<ng-container
*ngIf="
selectedProviderType === providerType.Duo ||
selectedProviderType === providerType.OrganizationDuo
"
>
<div id="duo-frame">
<iframe
id="duo_iframe"
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"
></iframe>
<!-- Duo -->
<ng-container *ngIf="isDuoProvider">
<div *ngIf="duoFrameless" class="tw-my-4">
<p
*ngIf="selectedProviderType === providerType.OrganizationDuo"
class="tw-mb-0 tw-text-center"
>
{{ "duoRequiredByOrgForAccount" | i18n }}
</p>
<p class="tw-text-center" *ngIf="!inPopout">
{{ "openExtensionInNewWindowToCompleteLogin" | i18n }}
</p>
<ng-container *ngIf="inPopout">
<p class="tw-text-center">{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}</p>
<ng-container *ngTemplateOutlet="duoRememberMe"></ng-container>
</ng-container>
</div>
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="remember">{{ "rememberMe" | i18n }}</label>
<input id="remember" type="checkbox" name="Remember" [(ngModel)]="remember" />
<ng-container *ngIf="!duoFrameless">
<div id="duo-frame">
<iframe
id="duo_iframe"
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"
></iframe>
</div>
<ng-container *ngTemplateOutlet="duoRememberMe"></ng-container>
</ng-container>
<ng-template #duoRememberMe>
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="remember">{{ "rememberMe" | i18n }}</label>
<input id="remember" type="checkbox" name="Remember" [(ngModel)]="remember" />
</div>
</div>
</div>
</div>
</ng-template>
</ng-container>
<div class="box-content-row" [hidden]="!showCaptcha()">
<iframe id="hcaptcha_iframe" height="80" sandbox="allow-scripts allow-same-origin"></iframe>
@ -134,12 +159,47 @@
<p class="text-center">{{ "noTwoStepProviders" | i18n }}</p>
<p class="text-center">{{ "noTwoStepProviders2" | i18n }}</p>
</div>
<!-- Buttons -->
<div class="content no-vpad" *ngIf="selectedProviderType != null">
<p class="text-center">
<button type="button" appStopClick (click)="anotherMethod()">
{{ "useAnotherTwoStepMethod" | i18n }}
<ng-container *ngIf="duoFrameless && isDuoProvider">
<button
*ngIf="inPopout"
bitButton
type="button"
class="tw-mb-2"
buttonType="primary"
[block]="true"
appStopClick
(click)="launchDuoFrameless()"
>
{{ "launchDuo" | i18n }}
</button>
</p>
<button
*ngIf="!inPopout"
bitButton
type="button"
class="tw-mb-2"
buttonType="primary"
[block]="true"
appStopClick
(click)="popoutCurrentPage()"
>
{{ "popoutExtension" | i18n }}
</button>
</ng-container>
<button
bitButton
type="button"
buttonType="secondary"
[block]="true"
appStopClick
(click)="anotherMethod()"
>
{{ "useAnotherTwoStepMethod" | i18n }}
</button>
<p *ngIf="selectedProviderType === providerType.Email" class="text-center">
<button type="button" appStopClick (click)="sendEmail(true)" [appApiAction]="emailPromise">
{{ "sendVerificationCodeEmailAgain" | i18n }}

View File

@ -1,6 +1,7 @@
import { Component, Inject } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { first } from "rxjs/operators";
import { Subject, Subscription } from "rxjs";
import { filter, first, takeUntil } from "rxjs/operators";
import { TwoFactorComponent as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor.component";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
@ -22,6 +23,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
import { DialogService } from "@bitwarden/components";
import { BrowserApi } from "../../platform/browser/browser-api";
import { ZonedMessageListenerService } from "../../platform/browser/zoned-message-listener.service";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import { closeTwoFactorAuthPopout } from "./utils/auth-popout-window";
@ -33,6 +35,9 @@ const BroadcasterSubscriptionId = "TwoFactorComponent";
templateUrl: "two-factor.component.html",
})
export class TwoFactorComponent extends BaseTwoFactorComponent {
private destroy$ = new Subject<void>();
inPopout = BrowserPopupUtils.inPopout(window);
constructor(
authService: AuthService,
router: Router,
@ -52,6 +57,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
configService: ConfigServiceAbstraction,
private dialogService: DialogService,
@Inject(WINDOW) protected win: Window,
private browserMessagingApi: ZonedMessageListenerService,
) {
super(
authService,
@ -158,6 +164,9 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
}
async ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
if (this.selectedProviderType === TwoFactorProviderType.WebAuthn && (await this.isLinux())) {
@ -182,7 +191,30 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
}
}
async popoutCurrentPage() {
await BrowserPopupUtils.openCurrentPagePopout(window);
}
async isLinux() {
return (await BrowserApi.getPlatformInfo()).os === "linux";
}
duoResultSubscription: Subscription;
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 }) => {
this.token = msg.code;
// This floating promise is intentional. We don't need to await the submit + awaiting in a subscription is not recommended.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.submit();
});
}
}
}

View File

@ -15,6 +15,7 @@ type ContentMessageWindowEventHandlers = {
[key: string]: ({ data, referrer }: ContentMessageWindowEventParams) => void;
authResult: ({ data, referrer }: ContentMessageWindowEventParams) => void;
webAuthnResult: ({ data, referrer }: ContentMessageWindowEventParams) => void;
duoResult: ({ data, referrer }: ContentMessageWindowEventParams) => void;
};
export {

View File

@ -60,6 +60,19 @@ describe("ContentMessageHandler", () => {
referrer: "localhost",
});
});
it("sends a duoResult message", () => {
const mockCode = "mockCode";
const command = "duoResult";
postWindowMessage({ command: command, code: mockCode });
expect(sendMessageSpy).toHaveBeenCalledWith({
command: command,
code: mockCode,
referrer: "localhost",
});
});
});
describe("handled extension messages", () => {

View File

@ -24,6 +24,8 @@ const windowMessageHandlers: ContentMessageWindowEventHandlers = {
handleAuthResultMessage(data, referrer),
webAuthnResult: ({ data, referrer }: { data: any; referrer: string }) =>
handleWebAuthnResultMessage(data, referrer),
duoResult: ({ data, referrer }: { data: any; referrer: string }) =>
handleDuoResultMessage(data, referrer),
};
/**
@ -37,6 +39,17 @@ async function handleAuthResultMessage(data: ContentMessageWindowData, referrer:
await chrome.runtime.sendMessage({ command, code, state, lastpass, referrer });
}
/**
* Handles the Duo 2FA result message from the window.
*
* @param data - Data from the window message
* @param referrer - The referrer of the window
*/
async function handleDuoResultMessage(data: ContentMessageWindowData, referrer: string) {
const { command, code } = data;
await chrome.runtime.sendMessage({ command, code: code, referrer });
}
/**
* Handles the webauthn result message from the window.
*

View File

@ -15,7 +15,7 @@ import { BitwardenToastModule } from "@bitwarden/angular/components/toastr.compo
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe";
import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe";
import { AvatarModule } from "@bitwarden/components";
import { AvatarModule, ButtonModule } from "@bitwarden/components";
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
import { AccountComponent } from "../auth/popup/account-switching/account.component";
@ -106,6 +106,7 @@ import "../platform/popup/locales";
FilePopoutCalloutComponent,
AvatarModule,
AccountComponent,
ButtonModule,
],
declarations: [
ActionButtonsComponent,

View File

@ -10,9 +10,7 @@
<div
class="col-5"
[ngClass]="{
'col-9':
selectedProviderType === providerType.Duo ||
selectedProviderType === providerType.OrganizationDuo
'col-9': !duoFrameless && isDuoProvider
}"
>
<p class="lead text-center mb-4">{{ title }}</p>
@ -83,18 +81,23 @@
<iframe id="webauthn_iframe" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
</ng-container>
<ng-container
*ngIf="
selectedProviderType === providerType.Duo ||
selectedProviderType === providerType.OrganizationDuo
"
>
<div id="duo-frame" class="mb-3">
<iframe
id="duo_iframe"
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"
></iframe>
</div>
<!-- Duo -->
<ng-container *ngIf="isDuoProvider">
<ng-container *ngIf="duoFrameless">
<p *ngIf="selectedProviderType === providerType.OrganizationDuo" class="tw-mb-0">
{{ "duoRequiredByOrgForAccount" | i18n }}
</p>
<p>{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}</p>
</ng-container>
<ng-container *ngIf="!duoFrameless">
<div id="duo-frame" class="mb-3">
<iframe
id="duo_iframe"
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"
></iframe>
</div>
</ng-container>
</ng-container>
<i
class="bwi bwi-spinner text-muted bwi-spin pull-right"
@ -124,15 +127,15 @@
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="d-flex mb-3">
<!-- Buttons -->
<div class="tw-flex tw-flex-col tw-mb-3">
<button
type="submit"
class="btn btn-primary btn-block btn-submit"
[disabled]="form.loading"
*ngIf="
selectedProviderType != null &&
selectedProviderType !== providerType.Duo &&
selectedProviderType !== providerType.OrganizationDuo &&
!isDuoProvider &&
selectedProviderType !== providerType.WebAuthn
"
>
@ -145,7 +148,16 @@
aria-hidden="true"
></i>
</button>
<a routerLink="/login" class="btn btn-outline-secondary btn-block ml-2 mt-0">
<button
(click)="launchDuoFrameless()"
type="button"
class="btn btn-primary btn-block"
[disabled]="form.loading"
*ngIf="duoFrameless && isDuoProvider"
>
<span> {{ "launchDuo" | i18n }} </span>
</button>
<a routerLink="/login" class="btn btn-outline-secondary btn-block">
{{ "cancel" | i18n }}
</a>
</div>

View File

@ -1,4 +1,4 @@
import { Component, Inject, ViewChild, ViewContainerRef } from "@angular/core";
import { Component, Inject, OnDestroy, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { TwoFactorComponent as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor.component";
@ -25,7 +25,7 @@ import { TwoFactorOptionsComponent } from "./two-factor-options.component";
templateUrl: "two-factor.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class TwoFactorComponent extends BaseTwoFactorComponent {
export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDestroy {
@ViewChild("twoFactorOptions", { read: ViewContainerRef, static: true })
twoFactorOptionsModal: ViewContainerRef;
@ -104,4 +104,28 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
},
});
};
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 } }) => {
this.token = msg.data.code;
await this.submit();
};
async ngOnDestroy() {
super.ngOnDestroy();
if (this.duoResultChannel) {
// clean up duo listener if it was initialized.
this.duoResultChannel.removeEventListener("message", this.handleDuoResultMessage);
this.duoResultChannel.close();
}
}
}

View File

@ -5918,6 +5918,15 @@
}
}
},
"launchDuoAndFollowStepsToFinishLoggingIn": {
"message": "Launch DUO and follow the steps to finish logging in."
},
"duoRequiredByOrgForAccount": {
"message": "DUO two-step login is required for your account."
},
"launchDuo": {
"message": "Launch DUO"
},
"turnOn": {
"message": "Turn on"
},

View File

@ -44,6 +44,11 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
formPromise: Promise<any>;
emailPromise: Promise<any>;
orgIdentifier: string = null;
duoFrameless = false;
duoFramelessUrl: string = null;
duoResultListenerInitialized = false;
onSuccessfulLogin: () => Promise<void>;
onSuccessfulLoginNavigate: () => Promise<void>;
@ -57,6 +62,13 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
protected forcePasswordResetRoute = "update-temp-password";
protected successRoute = "vault";
get isDuoProvider(): boolean {
return (
this.selectedProviderType === TwoFactorProviderType.Duo ||
this.selectedProviderType === TwoFactorProviderType.OrganizationDuo
);
}
constructor(
protected authService: AuthService,
protected router: Router,
@ -148,20 +160,42 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
break;
case TwoFactorProviderType.Duo:
case TwoFactorProviderType.OrganizationDuo:
setTimeout(() => {
DuoWebSDK.init({
iframe: undefined,
host: providerData.Host,
sig_request: providerData.Signature,
submit_callback: async (f: HTMLFormElement) => {
const sig = f.querySelector('input[name="sig_response"]') as HTMLInputElement;
if (sig != null) {
this.token = sig.value;
await this.submit();
}
},
});
}, 0);
// 2 Duo 2FA flows available
// 1. Duo Web SDK (iframe) - existing, to be deprecated
// 2. Duo Frameless (new tab) - new
// AuthUrl only exists for new Duo Frameless flow
if (providerData.AuthUrl) {
this.duoFrameless = true;
// 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 = providerData.AuthUrl;
} else {
// Duo Web SDK (iframe) flow
// TODO: remove when we remove the "duo-redirect" feature flag
setTimeout(() => {
DuoWebSDK.init({
iframe: undefined,
host: providerData.Host,
sig_request: providerData.Signature,
submit_callback: async (f: HTMLFormElement) => {
const sig = f.querySelector('input[name="sig_response"]') as HTMLInputElement;
if (sig != null) {
this.token = sig.value;
await this.submit();
}
},
});
}, 0);
}
break;
case TwoFactorProviderType.Email:
this.twoFactorEmail = providerData.Email;
@ -231,6 +265,9 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
return true;
}
// Each client will have own implementation
protected setupDuoResultListener(): void {}
private async handleLoginResponse(authResult: AuthResult) {
if (this.handleCaptchaRequired(authResult)) {
return;
@ -449,4 +486,9 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
get needsLock(): boolean {
return this.authService.authingWithSso() || this.authService.authingWithUserApiKey();
}
launchDuoFrameless() {
// Launch Duo Frameless flow in new tab
this.platformUtilsService.launchUri(this.duoFramelessUrl);
}
}