diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 6c10ea2b8a..988c9b0a24 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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." }, diff --git a/apps/browser/src/auth/popup/two-factor.component.html b/apps/browser/src/auth/popup/two-factor.component.html index 6d190120f5..361259e102 100644 --- a/apps/browser/src/auth/popup/two-factor.component.html +++ b/apps/browser/src/auth/popup/two-factor.component.html @@ -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 @@
+

{{ "insertYubiKey" | i18n }}

@@ -85,6 +86,7 @@
+
@@ -98,6 +100,7 @@
+

{{ "webAuthnNewTab" | i18n }}

@@ -106,26 +109,48 @@
- -
- + + +
+

+ {{ "duoRequiredByOrgForAccount" | i18n }} +

+ +

+ {{ "openExtensionInNewWindowToCompleteLogin" | i18n }} +

+ + +

{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}

+ + +
-
-
-
- - + + +
+ +
+ + +
+ + +
+
+
+ + +
-
+
@@ -134,12 +159,47 @@

{{ "noTwoStepProviders" | i18n }}

{{ "noTwoStepProviders2" | i18n }}

+
-

- -

+ + + + + +

-
+ +
- + + {{ "cancel" | i18n }}
diff --git a/apps/web/src/app/auth/two-factor.component.ts b/apps/web/src/app/auth/two-factor.component.ts index 2f6b3b27e1..16b8f5b932 100644 --- a/apps/web/src/app/auth/two-factor.component.ts +++ b/apps/web/src/app/auth/two-factor.component.ts @@ -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(); + } + } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 756b6afde3..2cd6da7da8 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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" }, diff --git a/libs/angular/src/auth/components/two-factor.component.ts b/libs/angular/src/auth/components/two-factor.component.ts index 8981d5a5d8..6e63e569c6 100644 --- a/libs/angular/src/auth/components/two-factor.component.ts +++ b/libs/angular/src/auth/components/two-factor.component.ts @@ -44,6 +44,11 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI formPromise: Promise; emailPromise: Promise; orgIdentifier: string = null; + + duoFrameless = false; + duoFramelessUrl: string = null; + duoResultListenerInitialized = false; + onSuccessfulLogin: () => Promise; onSuccessfulLoginNavigate: () => Promise; @@ -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); + } }