From acbff6953c64053dc06bbf691df6ab6e4c4e746d Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 24 Feb 2025 09:59:14 -0500 Subject: [PATCH] feat(2FA-UI-Refresh): [Auth/PM-8113] - 2FA Components Consolidation and UI Refresh (#12087) * PM-8113 - Deprecate TwoFactorComponentRefactor feature flag in favor of UnauthenticatedExtensionUIRefresh flag * PM-8113 - Rename all existing 2FA components as V1. * PM-8113 - TwoFactorAuthComp - Add comment explaining that tagged unused import is used a dialog. * PM-8113 - 2FA Auth Comp - deprecate captcha * PM-8113 - LoginStrategySvc - add todo for deprecation of captcha response * PM-8113 - TwoFactorAuth tests - remove captcha * PM-8113 - TwoFactorAuthComp HTML - remove captcha * PM-8113 - Web Two Factor Auth - update deps * PM-8113 - Move all new two-factor-auth components into libs/auth instead of libs/angular/src/auth * PM-8113 - Add new child-components folder to help differentiate between top level page component and child components * PM-8113 - Add todo for browser TwoFactorAuthEmailComponent * PM-8113 - TwoFactorAuth - progress on consolidation * PM-8113 - TwoFactorAuth - add TODO to ensure I don't miss web on success logic * PM-8113 - TwoFactorAuth - Deprecate browser implementation of two-factor-auth and move all logic into single component - WIP * PM-8113 - Bring across 2FA session timeout to new 2FA orchestrator comp * PM-8113 - Export TwoFactorAuth from libs/auth * PM-8113 - Fix 2FA Auth Comp tests by adding new service deps. * PM-8113 - Fix TwoFactorAuthExpiredComp imports + TwoFactorAuthComponent imports on other clients. * PM-8113 - 2FA Auth Comp - Progress on removing onSuccessfulLogin callback * PM-8113 - 2FA Auth - update deps to private as inheritance will no longer be used. * PM-8113 - TwoFactorAuthComp - Refactor init a bit. * PM-8113 - TwoFactorAuthComp - More naming refactors * PM-8113 - TwoFactorAuth - (1) more refactoring (2) removed onSuccessfulLoginNavigate (3) after successful login we always loginEmailService.clearValues() * PM-8113 - TwoFactorAuthComp Tests - clean up tests for removed callbacks. * PM-8113 - TwoFactorAuthComponent - refactor default success route handling * PM-8113 - TwoFactorAuthComp - More refactoring * PM-8113 - TwoFactorAuthComp - more refactors * PM-8113 - TwoFactorAuth - Remove unused service dep * PM-8113 - TwoFactorAuthComp - Refactor out unused button action text and move checks for continue button visibility into component * PM-8113 - TwoFactorAuthComponent - Add type for providerData * PM-8113 - TwoFactorAuthComponent - Add todo * PM-8113 - TwoFactorAuthComponent - Add client type * PM-8113 - TwoFactorAuth - implement browser specific SSO + 2FA logic * PM-8113 - TwoFactorService Abstraction - refactor to use proper functions + mark methods as abstract properly + add null return to getProviders * PM-8113 - Refactor 2FA Guard logic out of ngOnInit and into own tested guard. Updated all routes. * PM-8113 - TwoFactorAuthComponent - WIP on webauthn init. * PM-8113 - TwoFactorAuthComponent - pull webauthn fallback response handling into primary init with checks based on client for if it should be processed. * PM-8113 - TwoFactorAuthComponent - move linux popup width extension logic into ExtensionTwoFactorAuthComponentService * PM-8113 - WebTwoFactorAuthComponentService - add explicit override for web's determineLegacyKeyMigrationAction method. * PM-8113 - Implement new TwoFactorAuthComponentService .openPopoutIfApprovedForEmail2fa to replace extension specific init logic. * PM-8113 - TwoFactorAuthComponent - misc cleanup * PM-8113 - TwoFactorAuthComponent - more clean up * PM-8113 - TwoFactorAuthComponent - WIP on removing TDE callbacks * PM-8113 - TwoFactorAuthComponent - finish refactoring out all callbacks * PM-8113 - TwoFactorAuthComponent - remove now unused method * PM-8113 - TwoFactorAuthComponent - refactor routes. * PM-8113 - TwoFactorAuthComponent - add TODO * PM-8113 - TwoFactorAuthComp - isTrustedDeviceEncEnabled - add undefined check for optional window close. + Add todo * PM-8113 - TwoFactorAuthComponent tests - updated to pass * PM-8113 - (1) Consolidate TwoFactorAuthEmail component into new service architecture (2) Move openPopoutIfApprovedForEmail2fa to new TwoFactorAuthEmailComponentService * PM-8113 - Refactor libs/auth/2fa into barrel files. * PM-8113 - Move TwoFactorAuthEmail content to own folder. * PM-8113 - Move 2FA Duo to own comp folder. * PM-8113 - ExtensionTwoFactorAuthEmailComponentService - Add comment * PM-8113 - TwoFactorAuthEmailComponentService - add docs * PM-8113 - TwoFactorAuthDuoComponentService - define top level abstraction and each clients implementation of the duo2faResultListener * PM-8113 - TwoFactorAuthDuoCompService - add client specific handling for launchDuoFrameless * PM-8113 - Delete no longer used client specific two factor auth duo components. * PM-8113 - Register TwoFactorAuthDuoComponentService implementation in each client. * PM-8113 - TwoFactorAuthComp - add destroy ref to fix warnings. * PM-8113 - Remove accidentally checked in dev change * PM-8113 - TwoFactorAuthComp - (1) Add loading state (2) Add missing CheckboxModule import * PM-8113 - TwoFactorAuthDuoComponent - update takeUntilDestroyed to pass in destroy context as you can't use takeUntilDestroyed in ngOnInit without it. * PM-8113 - TwoFactorAuthWebAuthnComponent - remove no longer necessary webauthn new tab check as webauthn seems to work without it * PM-8113 - TwoFactorAuthWebAuthnComp - refactor names and add todo * PM-8113 - (1) Move WebAuthn 2FA comp to own folder (2) build out client service for new tab logic * PM-8113 - Register TwoFactorAuthWebAuthnComponentServices * PM-8113 - Tweak TwoFactorAuthWebAuthnComponentService and add to TwoFactorAuthWebAuthnComponent * PM-8113 - WebTwoFactorAuthDuoComponentService - fix type issue * PM-8113 - ExtensionTwoFactorAuthDuoComponentService - attempt to fix type issue. * PM-8113 - Remove ts-strict-ignore * PM-8113 - TwoFactorAuthWebAuthnComponent - satisfy strict typescript reqs. * PM-8113 - TwoFactorAuthComponent - some progress on strict TS conversion * PM-8113 - TwoFactorAuthComp - fixed all strict typescript issues. * PM-8113 - TwoFactorAuthComp - remove no longer necessary webauthn code * PM-8113 - ExtensionTwoFactorAuthComponentService - handleSso2faFlowSuccess - add more context * PM-8113 - TwoFactorAuthComp - TDE should use same success handler method * PM-8113 - Fix SSO + 2FA result handling by closing proper popout window * PM-8113 - Add todo * PM-8113 - Webauthn 2FA - As webauthn popout doesn't persist SSO state, have to genercize success logic (which should be a good thing but requires confirmation testing). * PM-8113 - Per main changes, remove deprecated I18nPipe from 2fa comps that use it. * PM-8113 - Remove more incorrect i18nPipes * PM-8113 - TwoFactorAuth + Webauthn - Refactor logic * PM-8113 - TwoFactorAuth - build submitting loading logic * PM-8113 - TwoFactorAuth - remove loading as submitting. * PM-8113 - TwoFactorAuth - update to latest authN session timeout logic * PM-8113 - AuthPopoutWindow - Add new single action popout for email 2FA so we can close it programmatically * PM-8113 - Update ExtensionTwoFactorAuthComponentService to close email 2FA single action popouts. * PM-8113 - Fix build after merge conflict issue * PM-8113 - 2FA - Duo & Email comps - strict typescript adherence. * PM-8113 - TwoFactorAuth - Clean up unused stuff and get tests passing * PM-8113 - Clean up used service method + TODO as I've confirmed it works for other flows. * PM-8113 - TODO: test all comp services * PM-8113 - TwoFactorAuthComponent Tests - fix tests by removing mock of removed method. * PM-8113 - Revert changes to login strategies to avoid scope creep for the sake of typescript strictness. * PM-8113 - ExtensionTwoFactorAuthComponentService tests * PM-8113 - Test ExtensionTwoFactorAuthDuoComponentService * PM-8113 - ExtensionTwoFactorAuthEmailComponentService - add tests * PM-8113 - Test ExtensionTwoFactorAuthWebAuthnComponentService * PM-8113 - Add 2fa icons (icons need tweaking still) * PM-8113 - TwoFactorAuthComponent - add setAnonLayoutDataByTwoFactorProviderType and handle email case as POC * PM-8113 - TwoFactorEmailComp - work on converting to new design * PM-8113 - Update icons with proper svg with scaling via viewbox * PM-8113 - Update icons to use proper classes * PM-8113 - 2FA Auth Comp - Progress on implementing design changes * PM-8113 - TwoFactorOptionsComponent - add todos * PM-8113 - 2fa Email Comp - add style changes per discussion with design * PM-8113 - TwoFactorAuthComponent - use2faRecoveryCode - build out method per discussion with design * PM-8113 - TwoFactorAuthComp - fix comp tests * PM-8113 - TwoFactorAuthComp - progress on adding 2fa provider page icons and subtitles * PM-8113 - Browser Translations - update duoTwoFactorRequiredPageSubtitle to match design discussion * PM-8113 - TwoFactorAuthComp - more work on getting page title / icons working * PM-8113 - Add todo * PM-8113 - TwoFactorAuthDuoComponent Html - remove text that was moved to page subtitle. * PM-8113 - 2FA Auth Comp - Duo icon works * PM-8113 - (1) Add Yubico logo icon (2) Rename Yubikey icon to security key icon * PM-8113 - TwoFactorAuthComp - remove icon from launch duo button per figma * PM-8113 - Mark old two-factor-options component as v1. * PM-8113 - Web - TwoFactorOptionsComponentV1 - Fix import * PM-8113 - Fix more imports * PM-8113 - Adjust translations based on meeting with Design * PM-8113 - TwoFactorOptionsComponent - deprecate recovery code functionality * PM-8113 - TwoFactorOptionsComponent - remove icon disable logic and unused imports * PM-8113 - 2FA Options Comp rewritten to match figma * PM-8113 - TwoFactorOptions - (1) Sort providers like setup screen (2) Add responsive scaling * PM-8113 - Webauthn 2FA - WIP on updating connectors to latest style * PM-8113 - Webauthn connector - clean up commented out code and restore block style * PM-8113 - TwoFactorAuthWebAuthn - Add loading state for iframe until webauthn ready * PM-8113 - Webauthn Iframe - update translation per figma * PM-8113 - TwoFactorAuthComp - per figma, put webauthn after checkbox. * PM-8113 - WebAuthn Fallback connector - UI refreshed * PM-8113 - Two Factor Options - Implement wrapping * PM-8113 - TwoFactorAuthAuthenticator - Remove text per figma * PM-8113 - TwoFactorAuthYubikey - Clean up design per figma * PM-8113 - Refactor all 2FA flows to use either reactive forms or programmatic submission so we get the benefit of onSubmit form validation like we have elsewhere. * PM-8113 - 2FA Auth Comp - for form validated 2FA methods, add enter support. * PM-8113 - TwoFactorAuthComp - Add loginSuccessHandlerService * PM-8113 - DesktopTwoFactorAuthDuoComponentService - add tests * PM-8113 - WebTwoFactorAuthDuoComponentService test file - WIP on tests * PM-8113 - WebTwoFactorAuthDuoComponentService - test listenForDuo2faResult * PM-8113 - TwoFactorAuthComp - (1) remove unused deps (2) get tests passing * PM-8113 - Add required to inputs * PM-8113 - TwoFactorAuth - Save off 2FA providers map so we can only show the select another 2FA method if the user actually has more than 1 configured 2FA method. * PM-8113 - Webauthn iframe styling must be adjusted per client so adjust desktop and browser extension * PM-8113 - TwoFactorAuthComp - Integrate latest ssoLoginService changes * PM-8113 - Desktop & Browser routing modules - add new page title per figma * PM-8113 - WebAuthn - added optional awaiting security key interaction button state to improve UX. * PM-8113 - TwoFactorAuthComp - refactor to avoid reactive race condition with retrieval of active user id. * PM-8113 - ExtensionTwoFactorAuthEmailComponentService - force close the popup since it has stopped closing when the popup opens. * PM-8113 - TwoFactorAuth - refactor enter key press to exempt non-applicable flows from enter key handling * PM-8113 - Refactor ExtensionTwoFactorAuthComponentService methods to solve issues with submission * PM-8113 - TwoFactorAuth - fix programmatic submit of form * PM-8113 - Fix ExtensionTwoFactorAuthComponentService tests * PM-8113 - Extension - Webauthn iframe - remove -10px margin * PM-8113 - Extension Routing module - 2FA screens need back button * PM-8113 - Get Duo working in extension * PM-8113 - TwoFactorOptions - tweak styling of row styling to better work for extension * PM-8113 - TwoFactorWebauthnComp - new tab button styling per figma * PM-8113 - 2FA Comp - Update logic for hiding / showing the remember me checkbox * PM-8113 - TwoFactorAuthWebAuthnComp - new tab flow - fix remember me * PM-8113 - Per PR feedback, add TODO for better provider and module structure for auth component client logic services. * PM-8113 - TwoFactorAuth - add missing TDE offboarding logic. * PM-8113 - TwoFactorAuthComponent tests - fix tests * PM-8113 - 2FA Auth Comp HTML - per PR feedback, remove unnecessary margin bottom * PM-8113 - 2FA Comp - per PR feedback, remove inSsoFlow as it isn't used. * PM-8113 - TwoFactorOptionsComp - Clean up no longer needed emitters. * PM-8113 - TwoFactorOptions - per PR feedback, clean up any usage * PM-8113 - TwoFactorAuthComp - per PR feedback, rename method from selectOtherTwofactorMethod to selectOtherTwoFactorMethod * PM-8113 - Per PR feedback, fix translations misspelling * PM-8113 - TwoFactorAuthSecurityKeyIcon - fix hardcoded value * PM-8113 - TwoFactorAuthSecurityKeyIcon - fix extra " * PM-8113 - TwoFactorAuthDuo - Per PR feedback, remove empty template. * PM-8113 - LooseComponentsModule - re-add accidentally removed component * PM-8113 - TwoFactorAuthWebAuthnIcon - per PR feedback, fix hardcoded stroke value. * PM-8113 - Desktop AppRoutingModule - per PR feedback, remove unnecessary AnonLayoutWrapperComponent component property. * PM-8113 - Update apps/browser/src/auth/services/extension-two-factor-auth-duo-component.service.spec.ts to fix misspelling Co-authored-by: rr-bw <102181210+rr-bw@users.noreply.github.com> * PM-8113 - TwoFactorAuthComp - Per PR feedback, add trim to token value * PM-8113 - TwoFactorService - add typescript strict * PM-8113 - TwoFactorService - per PR feedback, add jsdocs * PM-8113 - Per PR feedback, fix misspelling * PM-8113 - Webauthn fallback - per PR feedback fix stroke * PM-8113 - Update apps/web/src/connectors/webauthn-fallback.html Co-authored-by: rr-bw <102181210+rr-bw@users.noreply.github.com> * PM-8113 - Update libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-webauthn.icon.ts Co-authored-by: rr-bw <102181210+rr-bw@users.noreply.github.com> --------- Co-authored-by: rr-bw <102181210+rr-bw@users.noreply.github.com> --- apps/browser/src/_locales/en/messages.json | 37 ++ .../popup/two-factor-auth-duo.component.ts | 118 ---- .../popup/two-factor-auth-email.component.ts | 65 -- .../auth/popup/two-factor-auth.component.ts | 170 ----- ...l => two-factor-options-v1.component.html} | 0 ....ts => two-factor-options-v1.component.ts} | 6 +- ...nent.html => two-factor-v1.component.html} | 0 ...omponent.ts => two-factor-v1.component.ts} | 6 +- .../popup/utils/auth-popout-window.spec.ts | 40 ++ .../auth/popup/utils/auth-popout-window.ts | 41 +- ...-two-factor-auth-component.service.spec.ts | 189 ++++++ ...nsion-two-factor-auth-component.service.ts | 115 ++++ ...-factor-auth-duo-component.service.spec.ts | 93 +++ ...n-two-factor-auth-duo-component.service.ts | 62 ++ ...actor-auth-email-component.service.spec.ts | 93 +++ ...two-factor-auth-email-component.service.ts | 35 ++ ...or-auth-webauthn-component.service.spec.ts | 44 ++ ...-factor-auth-webauthn-component.service.ts | 30 + apps/browser/src/popup/app-routing.module.ts | 30 +- apps/browser/src/popup/app.module.ts | 8 +- apps/browser/src/popup/scss/misc.scss | 8 +- .../src/popup/services/services.module.ts | 36 ++ apps/desktop/src/app/app-routing.module.ts | 18 +- apps/desktop/src/app/app.module.ts | 8 +- .../src/app/services/services.module.ts | 12 + ...-factor-auth-duo-component.service.spec.ts | 93 +++ ...p-two-factor-auth-duo-component.service.ts | 56 ++ .../src/auth/two-factor-auth-duo.component.ts | 117 ---- .../src/auth/two-factor-auth.component.ts | 74 --- ...l => two-factor-options-v1.component.html} | 0 ....ts => two-factor-options-v1.component.ts} | 6 +- ...nent.html => two-factor-v1.component.html} | 0 ...omponent.ts => two-factor-v1.component.ts} | 10 +- apps/desktop/src/locales/en/messages.json | 37 ++ apps/desktop/src/scss/misc.scss | 8 +- apps/web/src/app/auth/core/services/index.ts | 1 + .../core/services/two-factor-auth/index.ts | 2 + .../web-two-factor-auth-component.service.ts | 14 + ...-factor-auth-duo-component.service.spec.ts | 83 +++ ...b-two-factor-auth-duo-component.service.ts | 31 + .../app/auth/two-factor-auth-duo.component.ts | 77 --- .../src/app/auth/two-factor-auth.component.ts | 144 ----- ...l => two-factor-options-v1.component.html} | 0 ....ts => two-factor-options-v1.component.ts} | 8 +- ...nent.html => two-factor-v1.component.html} | 0 ...omponent.ts => two-factor-v1.component.ts} | 12 +- apps/web/src/app/core/core.module.ts | 15 + apps/web/src/app/oss-routing.module.ts | 69 +- .../src/app/shared/loose-components.module.ts | 15 +- .../web/src/connectors/webauthn-fallback.html | 149 ++++- apps/web/src/connectors/webauthn-fallback.ts | 27 +- apps/web/src/connectors/webauthn.html | 15 +- apps/web/src/connectors/webauthn.ts | 76 ++- apps/web/src/locales/en/messages.json | 37 ++ apps/web/src/scss/plugins.scss | 2 +- apps/web/webpack.config.js | 4 +- ...o-factor-auth-authenticator.component.html | 16 - .../two-factor-auth-duo.component.html | 6 - .../two-factor-auth-duo.component.ts | 81 --- .../two-factor-auth-email.component.html | 19 - .../two-factor-auth-webauthn.component.html | 11 - .../two-factor-auth-yubikey.component.html | 17 - .../two-factor-auth.component.html | 76 --- .../two-factor-auth.component.ts | 414 ------------ .../two-factor-options.component.html | 51 -- .../two-factor-options.component.ts | 74 --- ....ts => two-factor-options-v1.component.ts} | 2 +- ...pec.ts => two-factor-v1.component.spec.ts} | 4 +- ...omponent.ts => two-factor-v1.component.ts} | 2 +- .../src/services/jslib-services.module.ts | 21 + ...wo-factor-component-refactor-route-swap.ts | 31 - .../angular/icons/two-factor-auth/index.ts | 6 + .../two-factor-auth-authenticator.icon.ts | 39 ++ .../two-factor-auth-duo.icon.ts | 20 + .../two-factor-auth-email.icon.ts | 18 + .../two-factor-auth-security-key.icon.ts | 52 ++ .../two-factor-auth-webauthn.icon.ts | 40 ++ .../two-factor-auth-yubico.icon.ts | 15 + libs/auth/src/angular/index.ts | 3 + .../two-factor-auth/child-components/index.ts | 3 + ...o-factor-auth-authenticator.component.html | 6 + ...two-factor-auth-authenticator.component.ts | 12 +- .../two-factor-auth-duo/index.ts | 1 + .../two-factor-auth-duo-component.service.ts | 32 + .../two-factor-auth-duo.component.ts | 96 +++ ...two-factor-auth-email-component.service.ts | 6 + .../two-factor-auth-email/index.ts | 2 + ...two-factor-auth-email-component.service.ts | 10 + .../two-factor-auth-email.component.html | 17 + .../two-factor-auth-email.component.ts | 62 +- ...-factor-auth-webauthn-component.service.ts | 12 + .../two-factor-auth-webauthn/index.ts | 2 + ...-factor-auth-webauthn-component.service.ts | 10 + .../two-factor-auth-webauthn.component.html | 24 + .../two-factor-auth-webauthn.component.ts | 78 ++- .../two-factor-auth-yubikey.component.html | 4 + .../two-factor-auth-yubikey.component.ts | 10 +- ...fault-two-factor-auth-component.service.ts | 19 + .../auth/src/angular/two-factor-auth/index.ts | 6 + .../two-factor-auth-component.service.ts | 66 ++ .../two-factor-auth.component.html | 108 ++++ .../two-factor-auth.component.spec.ts | 204 +++--- .../two-factor-auth.component.ts | 591 ++++++++++++++++++ .../two-factor-auth.guard.spec.ts | 74 +++ .../two-factor-auth/two-factor-auth.guard.ts | 33 + .../two-factor-options.component.html | 50 ++ .../two-factor-options.component.ts | 81 +++ .../abstractions/login-strategy.service.ts | 1 + .../auth/abstractions/two-factor.service.ts | 57 +- .../src/auth/services/two-factor.service.ts | 2 +- libs/common/src/auth/webauthn-iframe.ts | 5 +- libs/common/src/enums/feature-flag.enum.ts | 2 - 112 files changed, 3225 insertions(+), 1925 deletions(-) delete mode 100644 apps/browser/src/auth/popup/two-factor-auth-duo.component.ts delete mode 100644 apps/browser/src/auth/popup/two-factor-auth-email.component.ts delete mode 100644 apps/browser/src/auth/popup/two-factor-auth.component.ts rename apps/browser/src/auth/popup/{two-factor-options.component.html => two-factor-options-v1.component.html} (100%) rename apps/browser/src/auth/popup/{two-factor-options.component.ts => two-factor-options-v1.component.ts} (87%) rename apps/browser/src/auth/popup/{two-factor.component.html => two-factor-v1.component.html} (100%) rename apps/browser/src/auth/popup/{two-factor.component.ts => two-factor-v1.component.ts} (97%) create mode 100644 apps/browser/src/auth/services/extension-two-factor-auth-component.service.spec.ts create mode 100644 apps/browser/src/auth/services/extension-two-factor-auth-component.service.ts create mode 100644 apps/browser/src/auth/services/extension-two-factor-auth-duo-component.service.spec.ts create mode 100644 apps/browser/src/auth/services/extension-two-factor-auth-duo-component.service.ts create mode 100644 apps/browser/src/auth/services/extension-two-factor-auth-email-component.service.spec.ts create mode 100644 apps/browser/src/auth/services/extension-two-factor-auth-email-component.service.ts create mode 100644 apps/browser/src/auth/services/extension-two-factor-auth-webauthn-component.service.spec.ts create mode 100644 apps/browser/src/auth/services/extension-two-factor-auth-webauthn-component.service.ts create mode 100644 apps/desktop/src/auth/services/desktop-two-factor-auth-duo-component.service.spec.ts create mode 100644 apps/desktop/src/auth/services/desktop-two-factor-auth-duo-component.service.ts delete mode 100644 apps/desktop/src/auth/two-factor-auth-duo.component.ts delete mode 100644 apps/desktop/src/auth/two-factor-auth.component.ts rename apps/desktop/src/auth/{two-factor-options.component.html => two-factor-options-v1.component.html} (100%) rename apps/desktop/src/auth/{two-factor-options.component.ts => two-factor-options-v1.component.ts} (74%) rename apps/desktop/src/auth/{two-factor.component.html => two-factor-v1.component.html} (100%) rename apps/desktop/src/auth/{two-factor.component.ts => two-factor-v1.component.ts} (94%) create mode 100644 apps/web/src/app/auth/core/services/two-factor-auth/index.ts create mode 100644 apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-component.service.ts create mode 100644 apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-duo-component.service.spec.ts create mode 100644 apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-duo-component.service.ts delete mode 100644 apps/web/src/app/auth/two-factor-auth-duo.component.ts delete mode 100644 apps/web/src/app/auth/two-factor-auth.component.ts rename apps/web/src/app/auth/{two-factor-options.component.html => two-factor-options-v1.component.html} (100%) rename apps/web/src/app/auth/{two-factor-options.component.ts => two-factor-options-v1.component.ts} (84%) rename apps/web/src/app/auth/{two-factor.component.html => two-factor-v1.component.html} (100%) rename apps/web/src/app/auth/{two-factor.component.ts => two-factor-v1.component.ts} (93%) delete mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.html delete mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.html delete mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.ts delete mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth-email.component.html delete mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.html delete mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth-yubikey.component.html delete mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html delete mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts delete mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-options.component.html delete mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-options.component.ts rename libs/angular/src/auth/components/{two-factor-options.component.ts => two-factor-options-v1.component.ts} (96%) rename libs/angular/src/auth/components/{two-factor.component.spec.ts => two-factor-v1.component.spec.ts} (99%) rename libs/angular/src/auth/components/{two-factor.component.ts => two-factor-v1.component.ts} (99%) delete mode 100644 libs/angular/src/utils/two-factor-component-refactor-route-swap.ts create mode 100644 libs/auth/src/angular/icons/two-factor-auth/index.ts create mode 100644 libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-authenticator.icon.ts create mode 100644 libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-duo.icon.ts create mode 100644 libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-email.icon.ts create mode 100644 libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-security-key.icon.ts create mode 100644 libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-webauthn.icon.ts create mode 100644 libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-yubico.icon.ts create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/index.ts create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.html rename libs/{angular/src/auth/components/two-factor-auth => auth/src/angular/two-factor-auth/child-components}/two-factor-auth-authenticator.component.ts (64%) create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/index.ts create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo-component.service.ts create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo.component.ts create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/default-two-factor-auth-email-component.service.ts create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/index.ts create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email-component.service.ts create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.html rename libs/{angular/src/auth/components/two-factor-auth => auth/src/angular/two-factor-auth/child-components/two-factor-auth-email}/two-factor-auth-email.component.ts (61%) create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/default-two-factor-auth-webauthn-component.service.ts create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/index.ts create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn-component.service.ts create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component.html rename libs/{angular/src/auth/components/two-factor-auth => auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn}/two-factor-auth-webauthn.component.ts (61%) create mode 100644 libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-yubikey.component.html rename libs/{angular/src/auth/components/two-factor-auth => auth/src/angular/two-factor-auth/child-components}/two-factor-auth-yubikey.component.ts (69%) create mode 100644 libs/auth/src/angular/two-factor-auth/default-two-factor-auth-component.service.ts create mode 100644 libs/auth/src/angular/two-factor-auth/index.ts create mode 100644 libs/auth/src/angular/two-factor-auth/two-factor-auth-component.service.ts create mode 100644 libs/auth/src/angular/two-factor-auth/two-factor-auth.component.html rename libs/{angular/src/auth/components => auth/src/angular}/two-factor-auth/two-factor-auth.component.spec.ts (74%) create mode 100644 libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts create mode 100644 libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.spec.ts create mode 100644 libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.ts create mode 100644 libs/auth/src/angular/two-factor-auth/two-factor-options.component.html create mode 100644 libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a0f3e2755ed..239de13afdb 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -885,6 +885,21 @@ "logInToBitwarden": { "message": "Log in to Bitwarden" }, + "enterTheCodeSentToYourEmail": { + "message": "Enter the code sent to your email" + }, + "enterTheCodeFromYourAuthenticatorApp": { + "message": "Enter the code from your authenticator app" + }, + "pressYourYubiKeyToAuthenticate": { + "message": "Press your YubiKey to authenticate" + }, + "duoTwoFactorRequiredPageSubtitle": { + "message": "Duo two-step login is required for your account. Follow the steps below to finish logging in." + }, + "followTheStepsBelowToFinishLoggingIn": { + "message": "Follow the steps below to finish logging in." + }, "restartRegistration": { "message": "Restart registration" }, @@ -1374,12 +1389,22 @@ "rememberMe": { "message": "Remember me" }, + "dontAskAgainOnThisDeviceFor30Days": { + "message": "Don't ask again on this device for 30 days" + }, "sendVerificationCodeEmailAgain": { "message": "Send verification code email again" }, "useAnotherTwoStepMethod": { "message": "Use another two-step login method" + }, + "selectAnotherMethod": { + "message": "Select another method", + "description": "Select another two-step login method" }, + "useYourRecoveryCode": { + "message": "Use your recovery code" + }, "insertYubiKey": { "message": "Insert your YubiKey into your computer's USB port, then touch its button." }, @@ -1392,9 +1417,18 @@ "webAuthnNewTabOpen": { "message": "Open new tab" }, + "openInNewTab": { + "message": "Open in new tab" + }, "webAuthnAuthenticate": { "message": "Authenticate WebAuthn" }, + "readSecurityKey": { + "message": "Read security key" + }, + "awaitingSecurityKeyInteraction": { + "message": "Awaiting security key interaction..." + }, "loginUnavailable": { "message": "Login unavailable" }, @@ -1407,6 +1441,9 @@ "twoStepOptions": { "message": "Two-step login options" }, + "selectTwoStepLoginMethod": { + "message": "Select two-step login method" + }, "recoveryCodeDesc": { "message": "Lost access to all of your two-factor providers? Use your recovery code to turn off all two-factor providers from your account." }, 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 deleted file mode 100644 index 687dc683929..00000000000 --- a/apps/browser/src/auth/popup/two-factor-auth-duo.component.ts +++ /dev/null @@ -1,118 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { DialogModule } from "@angular/cdk/dialog"; -import { CommonModule } from "@angular/common"; -import { Component, OnDestroy, OnInit } 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"; - -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { AsyncActionsModule } from "../../../../../libs/components/src/async-actions"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { ButtonModule } from "../../../../../libs/components/src/button"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { FormFieldModule } from "../../../../../libs/components/src/form-field"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { LinkModule } from "../../../../../libs/components/src/link"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -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, - ], -}) -export class TwoFactorAuthDuoComponent - extends TwoFactorAuthDuoBaseComponent - implements OnInit, OnDestroy -{ - 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-email.component.ts b/apps/browser/src/auth/popup/two-factor-auth-email.component.ts deleted file mode 100644 index 7afe1eb889e..00000000000 --- a/apps/browser/src/auth/popup/two-factor-auth-email.component.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { DialogModule } from "@angular/cdk/dialog"; -import { CommonModule } from "@angular/common"; -import { Component, OnInit, inject } from "@angular/core"; -import { ReactiveFormsModule, FormsModule } from "@angular/forms"; - -import { TwoFactorAuthEmailComponent as TwoFactorAuthEmailBaseComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-email.component"; -import { JslibModule } from "@bitwarden/angular/jslib.module"; - -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { AsyncActionsModule } from "../../../../../libs/components/src/async-actions"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { ButtonModule } from "../../../../../libs/components/src/button"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { DialogService } from "../../../../../libs/components/src/dialog"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { FormFieldModule } from "../../../../../libs/components/src/form-field"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { LinkModule } from "../../../../../libs/components/src/link"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TypographyModule } from "../../../../../libs/components/src/typography"; -import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; - -@Component({ - standalone: true, - selector: "app-two-factor-auth-email", - templateUrl: - "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-email.component.html", - imports: [ - CommonModule, - JslibModule, - DialogModule, - ButtonModule, - LinkModule, - TypographyModule, - ReactiveFormsModule, - FormFieldModule, - AsyncActionsModule, - FormsModule, - ], -}) -export class TwoFactorAuthEmailComponent extends TwoFactorAuthEmailBaseComponent implements OnInit { - private dialogService = inject(DialogService); - - async ngOnInit(): Promise { - if (BrowserPopupUtils.inPopup(window)) { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "warning" }, - content: { key: "popup2faCloseMessage" }, - type: "warning", - }); - if (confirmed) { - await BrowserPopupUtils.openCurrentPagePopout(window); - return; - } - } - - await super.ngOnInit(); - } -} diff --git a/apps/browser/src/auth/popup/two-factor-auth.component.ts b/apps/browser/src/auth/popup/two-factor-auth.component.ts deleted file mode 100644 index f22bbbe202c..00000000000 --- a/apps/browser/src/auth/popup/two-factor-auth.component.ts +++ /dev/null @@ -1,170 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { CommonModule } from "@angular/common"; -import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; -import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { ActivatedRoute, Router, RouterLink } from "@angular/router"; - -import { TwoFactorAuthAuthenticatorComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-authenticator.component"; -import { TwoFactorAuthWebAuthnComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-webauthn.component"; -import { TwoFactorAuthYubikeyComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-yubikey.component"; -import { TwoFactorAuthComponent as BaseTwoFactorAuthComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth.component"; -import { TwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-options.component"; -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SyncService } from "@bitwarden/common/platform/sync"; -import { - ButtonModule, - FormFieldModule, - AsyncActionsModule, - CheckboxModule, - DialogModule, - LinkModule, - TypographyModule, - DialogService, - ToastService, -} from "@bitwarden/components"; - -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, -} from "../../../../../libs/auth/src/common/abstractions"; -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({ - standalone: true, - templateUrl: - "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html", - selector: "app-two-factor-auth", - imports: [ - CommonModule, - JslibModule, - DialogModule, - ButtonModule, - LinkModule, - TypographyModule, - ReactiveFormsModule, - FormFieldModule, - AsyncActionsModule, - RouterLink, - CheckboxModule, - TwoFactorOptionsComponent, - TwoFactorAuthEmailComponent, - TwoFactorAuthAuthenticatorComponent, - TwoFactorAuthYubikeyComponent, - TwoFactorAuthDuoComponent, - TwoFactorAuthWebAuthnComponent, - ], - providers: [I18nPipe], -}) -export class TwoFactorAuthComponent - extends BaseTwoFactorAuthComponent - implements OnInit, OnDestroy -{ - constructor( - protected loginStrategyService: LoginStrategyServiceAbstraction, - protected router: Router, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - environmentService: EnvironmentService, - dialogService: DialogService, - protected route: ActivatedRoute, - logService: LogService, - protected twoFactorService: TwoFactorService, - loginEmailService: LoginEmailServiceAbstraction, - userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - protected ssoLoginService: SsoLoginServiceAbstraction, - protected configService: ConfigService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - accountService: AccountService, - formBuilder: FormBuilder, - @Inject(WINDOW) protected win: Window, - private syncService: SyncService, - private messagingService: MessagingService, - toastService: ToastService, - ) { - super( - loginStrategyService, - router, - i18nService, - platformUtilsService, - environmentService, - dialogService, - route, - logService, - twoFactorService, - loginEmailService, - userDecryptionOptionsService, - ssoLoginService, - configService, - masterPasswordService, - accountService, - formBuilder, - win, - toastService, - ); - this.onSuccessfulLoginTdeNavigate = async () => { - this.win.close(); - }; - this.onSuccessfulLoginNavigate = this.goAfterLogIn; - } - - async ngOnInit(): Promise { - await super.ngOnInit(); - - if (this.route.snapshot.paramMap.has("webAuthnResponse")) { - // WebAuthn fallback response - this.selectedProviderType = TwoFactorProviderType.WebAuthn; - this.token = this.route.snapshot.paramMap.get("webAuthnResponse"); - this.onSuccessfulLogin = async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.syncService.fullSync(true); - this.messagingService.send("reloadPopup"); - window.close(); - }; - this.remember = this.route.snapshot.paramMap.get("remember") === "true"; - await this.submit(); - return; - } - - if (await BrowserPopupUtils.inPopout(this.win)) { - this.selectedProviderType = TwoFactorProviderType.Email; - } - - // WebAuthn prompt appears inside the popup on linux, and requires a larger popup width - // than usual to avoid cutting off the dialog. - if (this.selectedProviderType === TwoFactorProviderType.WebAuthn && (await this.isLinux())) { - document.body.classList.add("linux-webauthn"); - } - } - - async ngOnDestroy() { - if (this.selectedProviderType === TwoFactorProviderType.WebAuthn && (await this.isLinux())) { - document.body.classList.remove("linux-webauthn"); - } - } - - async isLinux() { - return (await BrowserApi.getPlatformInfo()).os === "linux"; - } -} diff --git a/apps/browser/src/auth/popup/two-factor-options.component.html b/apps/browser/src/auth/popup/two-factor-options-v1.component.html similarity index 100% rename from apps/browser/src/auth/popup/two-factor-options.component.html rename to apps/browser/src/auth/popup/two-factor-options-v1.component.html diff --git a/apps/browser/src/auth/popup/two-factor-options.component.ts b/apps/browser/src/auth/popup/two-factor-options-v1.component.ts similarity index 87% rename from apps/browser/src/auth/popup/two-factor-options.component.ts rename to apps/browser/src/auth/popup/two-factor-options-v1.component.ts index 6191d277add..0c71421fc04 100644 --- a/apps/browser/src/auth/popup/two-factor-options.component.ts +++ b/apps/browser/src/auth/popup/two-factor-options-v1.component.ts @@ -1,7 +1,7 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { TwoFactorOptionsComponent as BaseTwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-options.component"; +import { TwoFactorOptionsComponentV1 as BaseTwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-options-v1.component"; import { TwoFactorProviderDetails, TwoFactorService, @@ -12,9 +12,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl @Component({ selector: "app-two-factor-options", - templateUrl: "two-factor-options.component.html", + templateUrl: "two-factor-options-v1.component.html", }) -export class TwoFactorOptionsComponent extends BaseTwoFactorOptionsComponent { +export class TwoFactorOptionsComponentV1 extends BaseTwoFactorOptionsComponent { constructor( twoFactorService: TwoFactorService, router: Router, diff --git a/apps/browser/src/auth/popup/two-factor.component.html b/apps/browser/src/auth/popup/two-factor-v1.component.html similarity index 100% rename from apps/browser/src/auth/popup/two-factor.component.html rename to apps/browser/src/auth/popup/two-factor-v1.component.html diff --git a/apps/browser/src/auth/popup/two-factor.component.ts b/apps/browser/src/auth/popup/two-factor-v1.component.ts similarity index 97% rename from apps/browser/src/auth/popup/two-factor.component.ts rename to apps/browser/src/auth/popup/two-factor-v1.component.ts index 43230bd23f4..723432501e1 100644 --- a/apps/browser/src/auth/popup/two-factor.component.ts +++ b/apps/browser/src/auth/popup/two-factor-v1.component.ts @@ -5,7 +5,7 @@ import { ActivatedRoute, Router } from "@angular/router"; import { Subject, Subscription, firstValueFrom } from "rxjs"; import { filter, first, takeUntil } from "rxjs/operators"; -import { TwoFactorComponent as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor.component"; +import { TwoFactorComponentV1 as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor-v1.component"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { LoginStrategyServiceAbstraction, @@ -37,9 +37,9 @@ import { closeTwoFactorAuthWebAuthnPopout } from "./utils/auth-popout-window"; @Component({ selector: "app-two-factor", - templateUrl: "two-factor.component.html", + templateUrl: "two-factor-v1.component.html", }) -export class TwoFactorComponent extends BaseTwoFactorComponent implements OnInit, OnDestroy { +export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); inPopout = BrowserPopupUtils.inPopout(window); diff --git a/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts b/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts index deb71f73cd6..0b47fa4287e 100644 --- a/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts +++ b/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts @@ -10,6 +10,10 @@ import { openTwoFactorAuthWebAuthnPopout, closeTwoFactorAuthWebAuthnPopout, closeSsoAuthResultPopout, + openTwoFactorAuthEmailPopout, + closeTwoFactorAuthEmailPopout, + openTwoFactorAuthDuoPopout, + closeTwoFactorAuthDuoPopout, } from "./auth-popout-window"; describe("AuthPopoutWindow", () => { @@ -124,4 +128,40 @@ describe("AuthPopoutWindow", () => { expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(AuthPopoutType.twoFactorAuthWebAuthn); }); }); + + describe("openTwoFactorAuthEmailPopout", () => { + it("opens a window that facilitates two factor authentication via email", async () => { + await openTwoFactorAuthEmailPopout(); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/2fa", { + singleActionKey: AuthPopoutType.twoFactorAuthEmail, + }); + }); + }); + + describe("closeTwoFactorAuthEmailPopout", () => { + it("closes the two-factor authentication email window", async () => { + await closeTwoFactorAuthEmailPopout(); + + expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(AuthPopoutType.twoFactorAuthEmail); + }); + }); + + describe("openTwoFactorAuthDuoPopout", () => { + it("opens a window that facilitates two factor authentication via Duo", async () => { + await openTwoFactorAuthDuoPopout(); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/2fa", { + singleActionKey: AuthPopoutType.twoFactorAuthDuo, + }); + }); + }); + + describe("closeTwoFactorAuthDuoPopout", () => { + it("closes the two-factor authentication Duo window", async () => { + await closeTwoFactorAuthDuoPopout(); + + expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(AuthPopoutType.twoFactorAuthDuo); + }); + }); }); diff --git a/apps/browser/src/auth/popup/utils/auth-popout-window.ts b/apps/browser/src/auth/popup/utils/auth-popout-window.ts index 8d6e7fa92cd..2f135038315 100644 --- a/apps/browser/src/auth/popup/utils/auth-popout-window.ts +++ b/apps/browser/src/auth/popup/utils/auth-popout-window.ts @@ -7,7 +7,10 @@ const AuthPopoutType = { unlockExtension: "auth_unlockExtension", ssoAuthResult: "auth_ssoAuthResult", twoFactorAuthWebAuthn: "auth_twoFactorAuthWebAuthn", + twoFactorAuthEmail: "auth_twoFactorAuthEmail", + twoFactorAuthDuo: "auth_twoFactorAuthDuo", } as const; + const extensionUnlockUrls = new Set([ chrome.runtime.getURL("popup/index.html#/lock"), chrome.runtime.getURL("popup/index.html#/home"), @@ -87,12 +90,44 @@ async function openTwoFactorAuthWebAuthnPopout(twoFactorAuthWebAuthnData: { } /** - * Closes the two-factor authentication popout window. + * Closes the two-factor authentication WebAuthn popout window. */ async function closeTwoFactorAuthWebAuthnPopout() { await BrowserPopupUtils.closeSingleActionPopout(AuthPopoutType.twoFactorAuthWebAuthn); } +/** + * Opens a popout that facilitates two-factor authentication via email. + */ +async function openTwoFactorAuthEmailPopout() { + await BrowserPopupUtils.openPopout("popup/index.html#/2fa", { + singleActionKey: AuthPopoutType.twoFactorAuthEmail, + }); +} + +/** + * Closes the two-factor authentication email popout window. + */ +async function closeTwoFactorAuthEmailPopout() { + await BrowserPopupUtils.closeSingleActionPopout(AuthPopoutType.twoFactorAuthEmail); +} + +/** + * Opens the two-factor authentication Duo popout. + */ +async function openTwoFactorAuthDuoPopout() { + await BrowserPopupUtils.openPopout("popup/index.html#/2fa", { + singleActionKey: AuthPopoutType.twoFactorAuthDuo, + }); +} + +/** + * Closes the two-factor authentication Duo popout. + */ +async function closeTwoFactorAuthDuoPopout() { + await BrowserPopupUtils.closeSingleActionPopout(AuthPopoutType.twoFactorAuthDuo); +} + export { AuthPopoutType, openUnlockPopout, @@ -101,4 +136,8 @@ export { closeSsoAuthResultPopout, openTwoFactorAuthWebAuthnPopout, closeTwoFactorAuthWebAuthnPopout, + openTwoFactorAuthEmailPopout, + closeTwoFactorAuthEmailPopout, + openTwoFactorAuthDuoPopout, + closeTwoFactorAuthDuoPopout, }; diff --git a/apps/browser/src/auth/services/extension-two-factor-auth-component.service.spec.ts b/apps/browser/src/auth/services/extension-two-factor-auth-component.service.spec.ts new file mode 100644 index 00000000000..408bb5b0ca4 --- /dev/null +++ b/apps/browser/src/auth/services/extension-two-factor-auth-component.service.spec.ts @@ -0,0 +1,189 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +// Must mock modules before importing +jest.mock("../popup/utils/auth-popout-window", () => { + const originalModule = jest.requireActual("../popup/utils/auth-popout-window"); + + return { + ...originalModule, // avoid losing the original module's exports + closeSsoAuthResultPopout: jest.fn(), + closeTwoFactorAuthWebAuthnPopout: jest.fn(), + closeTwoFactorAuthEmailPopout: jest.fn(), + closeTwoFactorAuthDuoPopout: jest.fn(), + }; +}); + +jest.mock("../../platform/popup/browser-popup-utils", () => ({ + inSingleActionPopout: jest.fn(), +})); + +import { DuoLaunchAction } from "@bitwarden/auth/angular"; +import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; + +import { BrowserApi } from "../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; +import { + AuthPopoutType, + closeSsoAuthResultPopout, + closeTwoFactorAuthDuoPopout, + closeTwoFactorAuthEmailPopout, + closeTwoFactorAuthWebAuthnPopout, +} from "../popup/utils/auth-popout-window"; + +import { ExtensionTwoFactorAuthComponentService } from "./extension-two-factor-auth-component.service"; + +describe("ExtensionTwoFactorAuthComponentService", () => { + let extensionTwoFactorAuthComponentService: ExtensionTwoFactorAuthComponentService; + let window: MockProxy; + + beforeEach(() => { + jest.clearAllMocks(); + + window = mock(); + document.body.className = ""; // Reset any added classes between tests. + + extensionTwoFactorAuthComponentService = new ExtensionTwoFactorAuthComponentService(window); + }); + + describe("shouldCheckForWebAuthnQueryParamResponse", () => { + it("should return true for the extension", () => { + expect( + extensionTwoFactorAuthComponentService.shouldCheckForWebAuthnQueryParamResponse(), + ).toBe(true); + }); + }); + + describe("extendPopupWidthIfRequired", () => { + it("should add linux-webauthn class to body if selected2faProviderType is WebAuthn and isLinux is true", async () => { + jest + .spyOn(extensionTwoFactorAuthComponentService as unknown as any, "isLinux") + .mockResolvedValue(true); + + await extensionTwoFactorAuthComponentService.extendPopupWidthIfRequired( + TwoFactorProviderType.WebAuthn, + ); + expect(document.body.classList).toContain("linux-webauthn"); + }); + + it("should not add linux-webauthn class to body if selected2faProviderType is WebAuthn and isLinux is false", async () => { + jest + .spyOn(extensionTwoFactorAuthComponentService as unknown as any, "isLinux") + .mockResolvedValue(false); + + await extensionTwoFactorAuthComponentService.extendPopupWidthIfRequired( + TwoFactorProviderType.WebAuthn, + ); + expect(document.body.classList).not.toContain("linux-webauthn"); + }); + + it.each([ + [true, TwoFactorProviderType.Email], + [false, TwoFactorProviderType.Email], + ])( + "should not add linux-webauthn class to body if selected2faProviderType is not WebAuthn and isLinux is %s", + async (isLinux, selected2faProviderType) => { + jest + .spyOn(extensionTwoFactorAuthComponentService as unknown as any, "isLinux") + .mockResolvedValue(isLinux); + + await extensionTwoFactorAuthComponentService.extendPopupWidthIfRequired( + selected2faProviderType, + ); + + expect(document.body.classList).not.toContain("linux-webauthn"); + }, + ); + }); + + describe("removePopupWidthExtension", () => { + it("should remove linux-webauthn class from body", () => { + document.body.classList.add("linux-webauthn"); + extensionTwoFactorAuthComponentService.removePopupWidthExtension(); + expect(document.body.classList).not.toContain("linux-webauthn"); + }); + }); + + describe("closeSingleActionPopouts", () => { + it("should call closeSsoAuthResultPopout if in SSO auth result popout", async () => { + const inSingleActionPopoutSpy = jest + .spyOn(BrowserPopupUtils, "inSingleActionPopout") + .mockImplementation((_, key) => { + return key === AuthPopoutType.ssoAuthResult; + }); + + await extensionTwoFactorAuthComponentService.closeSingleActionPopouts(); + + expect(inSingleActionPopoutSpy).toHaveBeenCalledTimes(1); + expect(closeSsoAuthResultPopout).toHaveBeenCalled(); + }); + + it("should call closeTwoFactorAuthWebAuthnPopout if in two factor auth webauthn popout", async () => { + const inSingleActionPopoutSpy = jest + .spyOn(BrowserPopupUtils, "inSingleActionPopout") + .mockImplementation((_, key) => { + return key === AuthPopoutType.twoFactorAuthWebAuthn; + }); + + await extensionTwoFactorAuthComponentService.closeSingleActionPopouts(); + + expect(inSingleActionPopoutSpy).toHaveBeenCalledTimes(2); + expect(closeTwoFactorAuthWebAuthnPopout).toHaveBeenCalled(); + }); + + it("should call closeTwoFactorAuthEmailPopout if in two factor auth email popout", async () => { + const inSingleActionPopoutSpy = jest + .spyOn(BrowserPopupUtils, "inSingleActionPopout") + .mockImplementation((_, key) => { + return key === AuthPopoutType.twoFactorAuthEmail; + }); + + await extensionTwoFactorAuthComponentService.closeSingleActionPopouts(); + + expect(inSingleActionPopoutSpy).toHaveBeenCalledTimes(3); + expect(closeTwoFactorAuthEmailPopout).toHaveBeenCalled(); + }); + + it("should call closeTwoFactorAuthDuoPopout if in two factor auth duo popout", async () => { + const inSingleActionPopoutSpy = jest + .spyOn(BrowserPopupUtils, "inSingleActionPopout") + .mockImplementation((_, key) => { + return key === AuthPopoutType.twoFactorAuthDuo; + }); + + await extensionTwoFactorAuthComponentService.closeSingleActionPopouts(); + + expect(inSingleActionPopoutSpy).toHaveBeenCalledTimes(4); + expect(closeTwoFactorAuthDuoPopout).toHaveBeenCalled(); + }); + }); + + describe("reloadOpenWindows", () => { + it("should call reload open windows (exempting current)", async () => { + const reloadOpenWindowsSpy = jest.spyOn(BrowserApi, "reloadOpenWindows").mockImplementation(); + + extensionTwoFactorAuthComponentService.reloadOpenWindows(); + + expect(reloadOpenWindowsSpy).toHaveBeenCalledWith(true); + }); + }); + + describe("determineDuoLaunchAction", () => { + it("should return DIRECT_LAUNCH if in two factor auth duo popout", () => { + jest.spyOn(BrowserPopupUtils, "inSingleActionPopout").mockImplementation((_, key) => { + return key === AuthPopoutType.twoFactorAuthDuo; + }); + + expect(extensionTwoFactorAuthComponentService.determineDuoLaunchAction()).toBe( + DuoLaunchAction.DIRECT_LAUNCH, + ); + }); + + it("should return SINGLE_ACTION_POPOUT if not in two factor auth duo popout", () => { + jest.spyOn(BrowserPopupUtils, "inSingleActionPopout").mockImplementation(() => false); + + expect(extensionTwoFactorAuthComponentService.determineDuoLaunchAction()).toBe( + DuoLaunchAction.SINGLE_ACTION_POPOUT, + ); + }); + }); +}); diff --git a/apps/browser/src/auth/services/extension-two-factor-auth-component.service.ts b/apps/browser/src/auth/services/extension-two-factor-auth-component.service.ts new file mode 100644 index 00000000000..c11baadb595 --- /dev/null +++ b/apps/browser/src/auth/services/extension-two-factor-auth-component.service.ts @@ -0,0 +1,115 @@ +import { + DefaultTwoFactorAuthComponentService, + DuoLaunchAction, + TwoFactorAuthComponentService, +} from "@bitwarden/auth/angular"; +import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; + +import { BrowserApi } from "../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; +import { + AuthPopoutType, + closeSsoAuthResultPopout, + closeTwoFactorAuthDuoPopout, + closeTwoFactorAuthEmailPopout, + closeTwoFactorAuthWebAuthnPopout, +} from "../popup/utils/auth-popout-window"; + +export class ExtensionTwoFactorAuthComponentService + extends DefaultTwoFactorAuthComponentService + implements TwoFactorAuthComponentService +{ + constructor(private window: Window) { + super(); + } + + shouldCheckForWebAuthnQueryParamResponse(): boolean { + return true; + } + + async extendPopupWidthIfRequired(selected2faProviderType: TwoFactorProviderType): Promise { + // WebAuthn prompt appears inside the popup on linux, and requires a larger popup width + // than usual to avoid cutting off the dialog. + const isLinux = await this.isLinux(); + if (selected2faProviderType === TwoFactorProviderType.WebAuthn && isLinux) { + document.body.classList.add("linux-webauthn"); + } + } + + removePopupWidthExtension(): void { + document.body.classList.remove("linux-webauthn"); + } + + reloadOpenWindows(): void { + // Force sidebars (FF && Opera) to reload while exempting current window + // because we are just going to close the current window if it is in a popout + // or navigate forward if it is in the popup + BrowserApi.reloadOpenWindows(true); + } + + async closeSingleActionPopouts(): Promise { + // If we are in a single action popout, we don't need the popout anymore because the intent + // is for the user to be left on the web vault screen which tells them to continue in + // the browser extension (sidebar or popup). We don't want the user to be left with a + // floating, popped out extension which could be lost behind another window or minimized. + // Currently, the popped out window thinks it is active and wouldn't time out which + // leads to the security concern. So, we close the popped out extension to avoid this. + const inSsoAuthResultPopout = BrowserPopupUtils.inSingleActionPopout( + this.window, + AuthPopoutType.ssoAuthResult, + ); + if (inSsoAuthResultPopout) { + await closeSsoAuthResultPopout(); + return true; + } + + const inTwoFactorAuthWebAuthnPopout = BrowserPopupUtils.inSingleActionPopout( + this.window, + AuthPopoutType.twoFactorAuthWebAuthn, + ); + + if (inTwoFactorAuthWebAuthnPopout) { + await closeTwoFactorAuthWebAuthnPopout(); + return true; + } + + const inTwoFactorAuthEmailPopout = BrowserPopupUtils.inSingleActionPopout( + this.window, + AuthPopoutType.twoFactorAuthEmail, + ); + + if (inTwoFactorAuthEmailPopout) { + await closeTwoFactorAuthEmailPopout(); + return true; + } + + const inTwoFactorAuthDuoPopout = BrowserPopupUtils.inSingleActionPopout( + this.window, + AuthPopoutType.twoFactorAuthDuo, + ); + if (inTwoFactorAuthDuoPopout) { + await closeTwoFactorAuthDuoPopout(); + return true; + } + + return false; + } + + private async isLinux(): Promise { + const platformInfo = await BrowserApi.getPlatformInfo(); + return platformInfo.os === "linux"; + } + + determineDuoLaunchAction(): DuoLaunchAction { + const inTwoFactorAuthDuoPopout = BrowserPopupUtils.inSingleActionPopout( + this.window, + AuthPopoutType.twoFactorAuthDuo, + ); + + if (inTwoFactorAuthDuoPopout) { + return DuoLaunchAction.DIRECT_LAUNCH; + } + + return DuoLaunchAction.SINGLE_ACTION_POPOUT; + } +} diff --git a/apps/browser/src/auth/services/extension-two-factor-auth-duo-component.service.spec.ts b/apps/browser/src/auth/services/extension-two-factor-auth-duo-component.service.spec.ts new file mode 100644 index 00000000000..12772726a66 --- /dev/null +++ b/apps/browser/src/auth/services/extension-two-factor-auth-duo-component.service.spec.ts @@ -0,0 +1,93 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { + Environment, + EnvironmentService, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { ZonedMessageListenerService } from "../../platform/browser/zoned-message-listener.service"; +import I18nService from "../../platform/services/i18n.service"; + +import { ExtensionTwoFactorAuthDuoComponentService } from "./extension-two-factor-auth-duo-component.service"; + +describe("ExtensionTwoFactorAuthDuoComponentService", () => { + let extensionTwoFactorAuthDuoComponentService: ExtensionTwoFactorAuthDuoComponentService; + let browserMessagingApi: MockProxy; + let environmentService: MockProxy; + let i18nService: MockProxy; + let platformUtilsService: MockProxy; + + beforeEach(() => { + jest.clearAllMocks(); + + browserMessagingApi = mock(); + environmentService = mock(); + i18nService = mock(); + platformUtilsService = mock(); + + extensionTwoFactorAuthDuoComponentService = new ExtensionTwoFactorAuthDuoComponentService( + browserMessagingApi, + environmentService, + i18nService, + platformUtilsService, + ); + }); + + describe("listenForDuo2faResult$", () => { + it("should return an observable that emits a duo 2FA result when a duo result message is received", async () => { + const message = { + command: "duoResult", + code: "123456", + state: "abcdef", + }; + const expectedDuo2faResult = { + code: message.code, + state: message.state, + token: `${message.code}|${message.state}`, + }; + + const messageStream$ = new BehaviorSubject(message); + browserMessagingApi.messageListener$.mockReturnValue(messageStream$); + + const duo2faResult = await firstValueFrom( + extensionTwoFactorAuthDuoComponentService.listenForDuo2faResult$(), + ); + expect(duo2faResult).toEqual(expectedDuo2faResult); + }); + }); + + describe("launchDuoFrameless", () => { + it("should launch the duo frameless url", async () => { + // Arrange + const duoFramelessUrl = "https://duoFramelessUrl"; + const webVaultUrl = "https://webVaultUrl"; + + i18nService.t.mockImplementation((key) => key); + + const launchUrl = `${webVaultUrl}/duo-redirect-connector.html?duoFramelessUrl=${encodeURIComponent( + duoFramelessUrl, + )}&handOffMessage=${encodeURIComponent( + JSON.stringify({ + title: "youSuccessfullyLoggedIn", + message: "youMayCloseThisWindow", + isCountdown: false, + }), + )}`; + + const mockEnvironment = { + getWebVaultUrl: () => webVaultUrl, + } as unknown as Environment; + + const environmentBSubject = new BehaviorSubject(mockEnvironment); + environmentService.environment$ = environmentBSubject.asObservable(); + + // Act + await extensionTwoFactorAuthDuoComponentService.launchDuoFrameless(duoFramelessUrl); + + // Assert + expect(platformUtilsService.launchUri).toHaveBeenCalledWith(launchUrl); + }); + }); +}); diff --git a/apps/browser/src/auth/services/extension-two-factor-auth-duo-component.service.ts b/apps/browser/src/auth/services/extension-two-factor-auth-duo-component.service.ts new file mode 100644 index 00000000000..594e09fc50c --- /dev/null +++ b/apps/browser/src/auth/services/extension-two-factor-auth-duo-component.service.ts @@ -0,0 +1,62 @@ +import { filter, firstValueFrom, map, Observable } from "rxjs"; + +import { Duo2faResult, TwoFactorAuthDuoComponentService } from "@bitwarden/auth/angular"; +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 { openTwoFactorAuthDuoPopout } from "../../auth/popup/utils/auth-popout-window"; +import { ZonedMessageListenerService } from "../../platform/browser/zoned-message-listener.service"; + +interface Message { + command: string; + code: string; + state: string; +} + +export class ExtensionTwoFactorAuthDuoComponentService implements TwoFactorAuthDuoComponentService { + constructor( + private browserMessagingApi: ZonedMessageListenerService, + private environmentService: EnvironmentService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + ) {} + listenForDuo2faResult$(): Observable { + return this.browserMessagingApi.messageListener$().pipe( + filter((msg): msg is Message => { + return (msg as Message).command === "duoResult"; + }), + map((msg: Message) => { + return { + code: msg.code, + state: msg.state, + token: `${msg.code}|${msg.state}`, + } as Duo2faResult; + }), + ); + } + + async launchDuoFrameless(duoFramelessUrl: string): Promise { + 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(duoFramelessUrl) + + "&handOffMessage=" + + encodeURIComponent(JSON.stringify(duoHandOffMessage)); + this.platformUtilsService.launchUri(launchUrl); + } + + async openTwoFactorAuthDuoPopout(): Promise { + await openTwoFactorAuthDuoPopout(); + } +} diff --git a/apps/browser/src/auth/services/extension-two-factor-auth-email-component.service.spec.ts b/apps/browser/src/auth/services/extension-two-factor-auth-email-component.service.spec.ts new file mode 100644 index 00000000000..223375fd903 --- /dev/null +++ b/apps/browser/src/auth/services/extension-two-factor-auth-email-component.service.spec.ts @@ -0,0 +1,93 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { DialogService } from "@bitwarden/components"; + +// Must mock modules before importing +jest.mock("../popup/utils/auth-popout-window", () => { + const originalModule = jest.requireActual("../popup/utils/auth-popout-window"); + + return { + ...originalModule, // avoid losing the original module's exports + openTwoFactorAuthEmailPopout: jest.fn(), + }; +}); + +jest.mock("../../platform/popup/browser-popup-utils", () => ({ + inPopup: jest.fn(), +})); + +import { openTwoFactorAuthEmailPopout } from "../../auth/popup/utils/auth-popout-window"; +import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; + +import { ExtensionTwoFactorAuthEmailComponentService } from "./extension-two-factor-auth-email-component.service"; + +describe("ExtensionTwoFactorAuthEmailComponentService", () => { + let extensionTwoFactorAuthEmailComponentService: ExtensionTwoFactorAuthEmailComponentService; + + let dialogService: MockProxy; + let window: MockProxy; + + beforeEach(() => { + jest.clearAllMocks(); + + dialogService = mock(); + window = mock(); + + extensionTwoFactorAuthEmailComponentService = new ExtensionTwoFactorAuthEmailComponentService( + dialogService, + window, + ); + }); + + describe("openPopoutIfApprovedForEmail2fa", () => { + it("should open a popout if the user confirms the warning to popout the extension when in the popup", async () => { + // Arrange + dialogService.openSimpleDialog.mockResolvedValue(true); + + jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true); + + // Act + await extensionTwoFactorAuthEmailComponentService.openPopoutIfApprovedForEmail2fa(); + + // Assert + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "warning" }, + content: { key: "popup2faCloseMessage" }, + type: "warning", + }); + + expect(openTwoFactorAuthEmailPopout).toHaveBeenCalled(); + }); + + it("should not open a popout if the user cancels the warning to popout the extension when in the popup", async () => { + // Arrange + dialogService.openSimpleDialog.mockResolvedValue(false); + + jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true); + + // Act + await extensionTwoFactorAuthEmailComponentService.openPopoutIfApprovedForEmail2fa(); + + // Assert + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "warning" }, + content: { key: "popup2faCloseMessage" }, + type: "warning", + }); + + expect(openTwoFactorAuthEmailPopout).not.toHaveBeenCalled(); + }); + + it("should not open a popout if not in the popup", async () => { + // Arrange + jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(false); + + // Act + await extensionTwoFactorAuthEmailComponentService.openPopoutIfApprovedForEmail2fa(); + + // Assert + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(openTwoFactorAuthEmailPopout).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/auth/services/extension-two-factor-auth-email-component.service.ts b/apps/browser/src/auth/services/extension-two-factor-auth-email-component.service.ts new file mode 100644 index 00000000000..5d8d269412e --- /dev/null +++ b/apps/browser/src/auth/services/extension-two-factor-auth-email-component.service.ts @@ -0,0 +1,35 @@ +import { + DefaultTwoFactorAuthEmailComponentService, + TwoFactorAuthEmailComponentService, +} from "@bitwarden/auth/angular"; +import { DialogService } from "@bitwarden/components"; + +import { openTwoFactorAuthEmailPopout } from "../../auth/popup/utils/auth-popout-window"; +import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; + +// TODO: popup state persistence should eventually remove the need for this service +export class ExtensionTwoFactorAuthEmailComponentService + extends DefaultTwoFactorAuthEmailComponentService + implements TwoFactorAuthEmailComponentService +{ + constructor( + private dialogService: DialogService, + private window: Window, + ) { + super(); + } + + async openPopoutIfApprovedForEmail2fa(): Promise { + if (BrowserPopupUtils.inPopup(this.window)) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "warning" }, + content: { key: "popup2faCloseMessage" }, + type: "warning", + }); + if (confirmed) { + await openTwoFactorAuthEmailPopout(); + this.window.close(); + } + } + } +} diff --git a/apps/browser/src/auth/services/extension-two-factor-auth-webauthn-component.service.spec.ts b/apps/browser/src/auth/services/extension-two-factor-auth-webauthn-component.service.spec.ts new file mode 100644 index 00000000000..b53f05172ce --- /dev/null +++ b/apps/browser/src/auth/services/extension-two-factor-auth-webauthn-component.service.spec.ts @@ -0,0 +1,44 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { ExtensionTwoFactorAuthWebAuthnComponentService } from "./extension-two-factor-auth-webauthn-component.service"; + +describe("ExtensionTwoFactorAuthWebAuthnComponentService", () => { + let extensionTwoFactorAuthWebAuthnComponentService: ExtensionTwoFactorAuthWebAuthnComponentService; + + let platformUtilsService: MockProxy; + + beforeEach(() => { + jest.clearAllMocks(); + + platformUtilsService = mock(); + + extensionTwoFactorAuthWebAuthnComponentService = + new ExtensionTwoFactorAuthWebAuthnComponentService(platformUtilsService); + }); + + describe("shouldOpenWebAuthnInNewTab", () => { + it("should return false if the browser is Chrome", () => { + // Arrange + platformUtilsService.isChrome.mockReturnValue(true); + + // Act + const result = extensionTwoFactorAuthWebAuthnComponentService.shouldOpenWebAuthnInNewTab(); + + // Assert + expect(result).toBe(false); + }); + + it("should return true if the browser is not Chrome", () => { + // Arrange + platformUtilsService.isChrome.mockReturnValue(false); + + // Act + const result = extensionTwoFactorAuthWebAuthnComponentService.shouldOpenWebAuthnInNewTab(); + + // Assert + expect(result).toBe(true); + }); + }); +}); diff --git a/apps/browser/src/auth/services/extension-two-factor-auth-webauthn-component.service.ts b/apps/browser/src/auth/services/extension-two-factor-auth-webauthn-component.service.ts new file mode 100644 index 00000000000..84a54cbc12e --- /dev/null +++ b/apps/browser/src/auth/services/extension-two-factor-auth-webauthn-component.service.ts @@ -0,0 +1,30 @@ +import { + DefaultTwoFactorAuthWebAuthnComponentService, + TwoFactorAuthWebAuthnComponentService, +} from "@bitwarden/auth/angular"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +export class ExtensionTwoFactorAuthWebAuthnComponentService + extends DefaultTwoFactorAuthWebAuthnComponentService + implements TwoFactorAuthWebAuthnComponentService +{ + constructor(private platformUtilsService: PlatformUtilsService) { + super(); + } + + /** + * In the browser extension, we open webAuthn in a new web client tab sometimes due to inline + * WebAuthn Iframe's not working in some browsers. We open a 2FA popout upon successful + * completion of WebAuthn submission with query parameters to finish the 2FA process. + * @returns boolean + */ + shouldOpenWebAuthnInNewTab(): boolean { + const isChrome = this.platformUtilsService.isChrome(); + if (isChrome) { + // Chrome now supports WebAuthn in the iframe in the extension now. + return false; + } + + return true; + } +} diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 18b26913b1d..eb09f719aaf 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -18,19 +18,16 @@ import { unauthGuardFn, } from "@bitwarden/angular/auth/guards"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; -import { twofactorRefactorSwap } from "@bitwarden/angular/utils/two-factor-component-refactor-route-swap"; import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, DevicesIcon, - DeviceVerificationIcon, LockIcon, LoginComponent, LoginDecryptionOptionsComponent, LoginSecondaryContentComponent, LoginViaAuthRequestComponent, - NewDeviceVerificationComponent, PasswordHintComponent, RegistrationFinishComponent, RegistrationLockAltIcon, @@ -41,6 +38,10 @@ import { SetPasswordJitComponent, SsoComponent, TwoFactorTimeoutIcon, + TwoFactorAuthComponent, + TwoFactorAuthGuard, + NewDeviceVerificationComponent, + DeviceVerificationIcon, UserLockIcon, VaultIcon, } from "@bitwarden/auth/angular"; @@ -68,9 +69,8 @@ import { RemovePasswordComponent } from "../auth/popup/remove-password.component import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { SsoComponentV1 } from "../auth/popup/sso-v1.component"; -import { TwoFactorAuthComponent } from "../auth/popup/two-factor-auth.component"; -import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component"; -import { TwoFactorComponent } from "../auth/popup/two-factor.component"; +import { TwoFactorOptionsComponentV1 } from "../auth/popup/two-factor-options-v1.component"; +import { TwoFactorComponentV1 } from "../auth/popup/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { Fido2Component } from "../autofill/popup/fido2/fido2.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; @@ -153,9 +153,9 @@ const routes: Routes = [ canActivate: [fido2AuthGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, - ...twofactorRefactorSwap( - TwoFactorComponent, - AnonLayoutWrapperComponent, + ...unauthUiRefreshSwap( + TwoFactorComponentV1, + ExtensionAnonLayoutWrapperComponent, { path: "2fa", canActivate: [unauthGuardFn(unauthRouteOverrides)], @@ -163,14 +163,20 @@ const routes: Routes = [ }, { path: "2fa", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { elevation: 1 } satisfies RouteDataProperties, + canActivate: [unauthGuardFn(unauthRouteOverrides), TwoFactorAuthGuard], children: [ { path: "", component: TwoFactorAuthComponent, }, ], + data: { + elevation: 1, + pageTitle: { + key: "verifyIdentity", + }, + showBackButton: true, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, }, ), { @@ -198,7 +204,7 @@ const routes: Routes = [ }, { path: "2fa-options", - component: TwoFactorOptionsComponent, + component: TwoFactorOptionsComponentV1, canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { elevation: 1 } satisfies RouteDataProperties, }, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 3046ec7916a..a190cb134ac 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -31,8 +31,8 @@ import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component"; import { SsoComponentV1 } from "../auth/popup/sso-v1.component"; -import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component"; -import { TwoFactorComponent } from "../auth/popup/two-factor.component"; +import { TwoFactorOptionsComponentV1 } from "../auth/popup/two-factor-options-v1.component"; +import { TwoFactorComponentV1 } from "../auth/popup/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component"; @@ -105,8 +105,8 @@ import "../platform/popup/locales"; SetPasswordComponent, SsoComponentV1, TabsV2Component, - TwoFactorComponent, - TwoFactorOptionsComponent, + TwoFactorComponentV1, + TwoFactorOptionsComponentV1, UpdateTempPasswordComponent, UserVerificationComponent, VaultTimeoutInputComponent, diff --git a/apps/browser/src/popup/scss/misc.scss b/apps/browser/src/popup/scss/misc.scss index 86623e395e8..d1308d26180 100644 --- a/apps/browser/src/popup/scss/misc.scss +++ b/apps/browser/src/popup/scss/misc.scss @@ -211,15 +211,13 @@ p.lead { } #web-authn-frame { - background: url("../images/loading.svg") 0 0 no-repeat; width: 100%; - height: 310px; - margin-bottom: -10px; + height: 40px; iframe { - width: 100%; - height: 100%; border: none; + height: 100%; + width: 100%; } } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index eeceb1d4c47..3bdb3b79d1c 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -19,12 +19,17 @@ import { SafeInjectionToken, SECURE_STORAGE, SYSTEM_THEME_OBSERVABLE, + WINDOW, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { AnonLayoutWrapperDataService, LoginComponentService, LoginDecryptionOptionsService, + TwoFactorAuthComponentService, + TwoFactorAuthEmailComponentService, + TwoFactorAuthDuoComponentService, + TwoFactorAuthWebAuthnComponentService, SsoComponentService, } from "@bitwarden/auth/angular"; import { @@ -129,6 +134,10 @@ import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extensio import { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service"; import { ExtensionSsoComponentService } from "../../auth/popup/login/extension-sso-component.service"; import { ExtensionLoginDecryptionOptionsService } from "../../auth/popup/login-decryption-options/extension-login-decryption-options.service"; +import { ExtensionTwoFactorAuthComponentService } from "../../auth/services/extension-two-factor-auth-component.service"; +import { ExtensionTwoFactorAuthDuoComponentService } from "../../auth/services/extension-two-factor-auth-duo-component.service"; +import { ExtensionTwoFactorAuthEmailComponentService } from "../../auth/services/extension-two-factor-auth-email-component.service"; +import { ExtensionTwoFactorAuthWebAuthnComponentService } from "../../auth/services/extension-two-factor-auth-webauthn-component.service"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; import AutofillService from "../../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service"; @@ -137,6 +146,7 @@ import { ExtensionLockComponentService } from "../../key-management/lock/service import { BrowserApi } from "../../platform/browser/browser-api"; import { runInsideAngular } from "../../platform/browser/run-inside-angular.operator"; /* eslint-disable no-restricted-imports */ +import { ZonedMessageListenerService } from "../../platform/browser/zoned-message-listener.service"; import { ChromeMessageSender } from "../../platform/messaging/chrome-message.sender"; /* eslint-enable no-restricted-imports */ import { OffscreenDocumentService } from "../../platform/offscreen-document/abstractions/offscreen-document"; @@ -531,6 +541,32 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionLockComponentService, deps: [], }), + // TODO: PM-18182 - Refactor component services into lazy loaded modules + safeProvider({ + provide: TwoFactorAuthComponentService, + useClass: ExtensionTwoFactorAuthComponentService, + deps: [WINDOW], + }), + safeProvider({ + provide: TwoFactorAuthEmailComponentService, + useClass: ExtensionTwoFactorAuthEmailComponentService, + deps: [DialogService, WINDOW], + }), + safeProvider({ + provide: TwoFactorAuthWebAuthnComponentService, + useClass: ExtensionTwoFactorAuthWebAuthnComponentService, + deps: [PlatformUtilsService], + }), + safeProvider({ + provide: TwoFactorAuthDuoComponentService, + useClass: ExtensionTwoFactorAuthDuoComponentService, + deps: [ + ZonedMessageListenerService, + EnvironmentService, + I18nServiceAbstraction, + PlatformUtilsService, + ], + }), safeProvider({ provide: Fido2UserVerificationService, useClass: Fido2UserVerificationService, diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 0d91d864926..b9dccf70322 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -16,7 +16,6 @@ import { unauthGuardFn, } from "@bitwarden/angular/auth/guards"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; -import { twofactorRefactorSwap } from "@bitwarden/angular/utils/two-factor-component-refactor-route-swap"; import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards"; import { AnonLayoutWrapperComponent, @@ -39,6 +38,8 @@ import { DevicesIcon, SsoComponent, TwoFactorTimeoutIcon, + TwoFactorAuthComponent, + TwoFactorAuthGuard, NewDeviceVerificationComponent, DeviceVerificationIcon, } from "@bitwarden/auth/angular"; @@ -59,8 +60,7 @@ import { LoginViaAuthRequestComponentV1 } from "../auth/login/login-via-auth-req import { RemovePasswordComponent } from "../auth/remove-password.component"; import { SetPasswordComponent } from "../auth/set-password.component"; import { SsoComponentV1 } from "../auth/sso-v1.component"; -import { TwoFactorAuthComponent } from "../auth/two-factor-auth.component"; -import { TwoFactorComponent } from "../auth/two-factor.component"; +import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { VaultComponent } from "../vault/app/vault/vault.component"; @@ -82,22 +82,26 @@ const routes: Routes = [ children: [], // Children lets us have an empty component. canActivate: [redirectGuard({ loggedIn: "/vault", loggedOut: "/login", locked: "/lock" })], }, - ...twofactorRefactorSwap( - TwoFactorComponent, + ...unauthUiRefreshSwap( + TwoFactorComponentV1, AnonLayoutWrapperComponent, { path: "2fa", }, { path: "2fa", - component: AnonLayoutWrapperComponent, + canActivate: [unauthGuardFn(), TwoFactorAuthGuard], children: [ { path: "", component: TwoFactorAuthComponent, - canActivate: [unauthGuardFn()], }, ], + data: { + pageTitle: { + key: "verifyIdentity", + }, + } satisfies RouteDataProperties & AnonLayoutWrapperData, }, ), { diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index ca678621d51..dce98cde9bc 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -18,8 +18,8 @@ import { LoginModule } from "../auth/login/login.module"; import { RemovePasswordComponent } from "../auth/remove-password.component"; import { SetPasswordComponent } from "../auth/set-password.component"; import { SsoComponentV1 } from "../auth/sso-v1.component"; -import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component"; -import { TwoFactorComponent } from "../auth/two-factor.component"; +import { TwoFactorOptionsComponentV1 } from "../auth/two-factor-options-v1.component"; +import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { PremiumComponent } from "../billing/app/accounts/premium.component"; import { SshAgentService } from "../platform/services/ssh-agent.service"; @@ -86,9 +86,9 @@ import { SendComponent } from "./tools/send/send.component"; SetPasswordComponent, SettingsComponent, ShareComponent, + TwoFactorComponentV1, SsoComponentV1, - TwoFactorComponent, - TwoFactorOptionsComponent, + TwoFactorOptionsComponentV1, UpdateTempPasswordComponent, VaultComponent, VaultTimeoutInputComponent, diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 7453fc453cf..83a2c6bad5c 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -26,6 +26,7 @@ import { SetPasswordJitService, SsoComponentService, DefaultSsoComponentService, + TwoFactorAuthDuoComponentService, } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, @@ -103,6 +104,7 @@ import { LockComponentService } from "@bitwarden/key-management-ui"; import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service"; import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; +import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service"; import { DesktopFido2UserInterfaceService } from "../../autofill/services/desktop-fido2-user-interface.service"; @@ -398,6 +400,16 @@ const safeProviders: SafeProvider[] = [ SsoUrlService, ], }), + safeProvider({ + provide: TwoFactorAuthDuoComponentService, + useClass: DesktopTwoFactorAuthDuoComponentService, + deps: [ + MessageListener, + EnvironmentService, + I18nServiceAbstraction, + PlatformUtilsServiceAbstraction, + ], + }), safeProvider({ provide: SdkClientFactory, useClass: flagEnabled("sdk") ? DefaultSdkClientFactory : NoopSdkClientFactory, diff --git a/apps/desktop/src/auth/services/desktop-two-factor-auth-duo-component.service.spec.ts b/apps/desktop/src/auth/services/desktop-two-factor-auth-duo-component.service.spec.ts new file mode 100644 index 00000000000..12ca8013b1d --- /dev/null +++ b/apps/desktop/src/auth/services/desktop-two-factor-auth-duo-component.service.spec.ts @@ -0,0 +1,93 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { + Environment, + 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 { MessageListener } from "@bitwarden/common/platform/messaging"; + +import { DesktopTwoFactorAuthDuoComponentService } from "./desktop-two-factor-auth-duo-component.service"; + +describe("DesktopTwoFactorAuthDuoComponentService", () => { + let desktopTwoFactorAuthDuoComponentService: DesktopTwoFactorAuthDuoComponentService; + let messageListener: MockProxy; + let environmentService: MockProxy; + let i18nService: MockProxy; + let platformUtilsService: MockProxy; + + beforeEach(() => { + jest.clearAllMocks(); + + messageListener = mock(); + environmentService = mock(); + i18nService = mock(); + platformUtilsService = mock(); + + desktopTwoFactorAuthDuoComponentService = new DesktopTwoFactorAuthDuoComponentService( + messageListener, + environmentService, + i18nService, + platformUtilsService, + ); + }); + + describe("listenForDuo2faResult$", () => { + it("should return an observable that emits a duo 2FA result when a duo result message is received", async () => { + const message: { code: string; state: string } = { + code: "123456", + state: "abcdef", + }; + const expectedDuo2faResult = { + code: message.code, + state: message.state, + token: `${message.code}|${message.state}`, + }; + + const messages = new BehaviorSubject(message); + messageListener.messages$.mockReturnValue(messages); + + const duo2faResult = await firstValueFrom( + desktopTwoFactorAuthDuoComponentService.listenForDuo2faResult$(), + ); + expect(duo2faResult).toEqual(expectedDuo2faResult); + }); + }); + + describe("launchDuoFrameless", () => { + it("should build and launch the duo frameless URL", async () => { + // Arrange + const duoFramelessUrl = "https://duoFramelessUrl"; + const webVaultUrl = "https://webVaultUrl"; + + i18nService.t.mockImplementation((key) => key); + + const handOffMessage = { + title: "youSuccessfullyLoggedIn", + message: "youMayCloseThisWindow", + isCountdown: false, + }; + + const mockEnvironment = { + getWebVaultUrl: () => webVaultUrl, + } as unknown as Environment; + const environmentBSubject = new BehaviorSubject(mockEnvironment); + environmentService.environment$ = environmentBSubject.asObservable(); + + // Act + await desktopTwoFactorAuthDuoComponentService.launchDuoFrameless(duoFramelessUrl); + + // Assert + const launchUrl = + webVaultUrl + + "/duo-redirect-connector.html" + + "?duoFramelessUrl=" + + encodeURIComponent(duoFramelessUrl) + + "&handOffMessage=" + + encodeURIComponent(JSON.stringify(handOffMessage)); + expect(platformUtilsService.launchUri).toHaveBeenCalledWith(launchUrl); + }); + }); +}); diff --git a/apps/desktop/src/auth/services/desktop-two-factor-auth-duo-component.service.ts b/apps/desktop/src/auth/services/desktop-two-factor-auth-duo-component.service.ts new file mode 100644 index 00000000000..eef03ca5b53 --- /dev/null +++ b/apps/desktop/src/auth/services/desktop-two-factor-auth-duo-component.service.ts @@ -0,0 +1,56 @@ +import { firstValueFrom, map, Observable } from "rxjs"; + +import { TwoFactorAuthDuoComponentService, Duo2faResult } from "@bitwarden/auth/angular"; +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 { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging"; + +// TODO: PM-16209 We should create a Duo2faMessageListenerService that listens for messages from duo +// and this command definition should move to that file. +// We should explore consolidating the messaging approach across clients - i.e., we +// should use the same command definition across all clients. We use duoResult on extension for no real +// benefit. +export const DUO_2FA_RESULT_COMMAND = new CommandDefinition<{ code: string; state: string }>( + "duoCallback", +); + +export class DesktopTwoFactorAuthDuoComponentService implements TwoFactorAuthDuoComponentService { + constructor( + private messageListener: MessageListener, + private environmentService: EnvironmentService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + ) {} + listenForDuo2faResult$(): Observable { + return this.messageListener.messages$(DUO_2FA_RESULT_COMMAND).pipe( + map((msg) => { + return { + code: msg.code, + state: msg.state, + token: `${msg.code}|${msg.state}`, + } as Duo2faResult; + }), + ); + } + + async launchDuoFrameless(duoFramelessUrl: string): Promise { + 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(duoFramelessUrl) + + "&handOffMessage=" + + encodeURIComponent(JSON.stringify(duoHandOffMessage)); + this.platformUtilsService.launchUri(launchUrl); + } +} diff --git a/apps/desktop/src/auth/two-factor-auth-duo.component.ts b/apps/desktop/src/auth/two-factor-auth-duo.component.ts deleted file mode 100644 index c238b753b64..00000000000 --- a/apps/desktop/src/auth/two-factor-auth-duo.component.ts +++ /dev/null @@ -1,117 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { DialogModule } from "@angular/cdk/dialog"; -import { CommonModule } from "@angular/common"; -import { Component, NgZone, OnDestroy, OnInit } 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"; - -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -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 - implements OnInit, OnDestroy -{ - 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 deleted file mode 100644 index d8b80c28df1..00000000000 --- a/apps/desktop/src/auth/two-factor-auth.component.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { DialogModule } from "@angular/cdk/dialog"; -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; -import { ReactiveFormsModule } from "@angular/forms"; -import { RouterLink } from "@angular/router"; - -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorAuthAuthenticatorComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorAuthEmailComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-email.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorAuthWebAuthnComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorAuthYubikeyComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-yubikey.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorAuthComponent as BaseTwoFactorAuthComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorOptionsComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-options.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { JslibModule } from "../../../../libs/angular/src/jslib.module"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { AsyncActionsModule } from "../../../../libs/components/src/async-actions"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { ButtonModule } from "../../../../libs/components/src/button"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { CheckboxModule } from "../../../../libs/components/src/checkbox"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { FormFieldModule } from "../../../../libs/components/src/form-field"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { LinkModule } from "../../../../libs/components/src/link"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TypographyModule } from "../../../../libs/components/src/typography"; - -import { TwoFactorAuthDuoComponent } from "./two-factor-auth-duo.component"; - -@Component({ - standalone: true, - templateUrl: - "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html", - selector: "app-two-factor-auth", - imports: [ - CommonModule, - JslibModule, - DialogModule, - ButtonModule, - LinkModule, - TypographyModule, - ReactiveFormsModule, - FormFieldModule, - AsyncActionsModule, - RouterLink, - CheckboxModule, - TwoFactorOptionsComponent, - TwoFactorAuthEmailComponent, - TwoFactorAuthAuthenticatorComponent, - TwoFactorAuthYubikeyComponent, - TwoFactorAuthDuoComponent, - TwoFactorAuthWebAuthnComponent, - ], -}) -export class TwoFactorAuthComponent extends BaseTwoFactorAuthComponent {} diff --git a/apps/desktop/src/auth/two-factor-options.component.html b/apps/desktop/src/auth/two-factor-options-v1.component.html similarity index 100% rename from apps/desktop/src/auth/two-factor-options.component.html rename to apps/desktop/src/auth/two-factor-options-v1.component.html diff --git a/apps/desktop/src/auth/two-factor-options.component.ts b/apps/desktop/src/auth/two-factor-options-v1.component.ts similarity index 74% rename from apps/desktop/src/auth/two-factor-options.component.ts rename to apps/desktop/src/auth/two-factor-options-v1.component.ts index 624d003c91f..1cb440a5f5f 100644 --- a/apps/desktop/src/auth/two-factor-options.component.ts +++ b/apps/desktop/src/auth/two-factor-options-v1.component.ts @@ -1,7 +1,7 @@ import { Component } from "@angular/core"; import { Router } from "@angular/router"; -import { TwoFactorOptionsComponent as BaseTwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-options.component"; +import { TwoFactorOptionsComponentV1 as BaseTwoFactorOptionsComponentV1 } from "@bitwarden/angular/auth/components/two-factor-options-v1.component"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -9,9 +9,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl @Component({ selector: "app-two-factor-options", - templateUrl: "two-factor-options.component.html", + templateUrl: "two-factor-options-v1.component.html", }) -export class TwoFactorOptionsComponent extends BaseTwoFactorOptionsComponent { +export class TwoFactorOptionsComponentV1 extends BaseTwoFactorOptionsComponentV1 { constructor( twoFactorService: TwoFactorService, router: Router, diff --git a/apps/desktop/src/auth/two-factor.component.html b/apps/desktop/src/auth/two-factor-v1.component.html similarity index 100% rename from apps/desktop/src/auth/two-factor.component.html rename to apps/desktop/src/auth/two-factor-v1.component.html diff --git a/apps/desktop/src/auth/two-factor.component.ts b/apps/desktop/src/auth/two-factor-v1.component.ts similarity index 94% rename from apps/desktop/src/auth/two-factor.component.ts rename to apps/desktop/src/auth/two-factor-v1.component.ts index 7f4525c5f14..26a6f81b88c 100644 --- a/apps/desktop/src/auth/two-factor.component.ts +++ b/apps/desktop/src/auth/two-factor-v1.component.ts @@ -4,7 +4,7 @@ import { Component, Inject, NgZone, OnDestroy, ViewChild, ViewContainerRef } fro import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; -import { TwoFactorComponent as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor.component"; +import { TwoFactorComponentV1 as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor-v1.component"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { @@ -29,16 +29,16 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; -import { TwoFactorOptionsComponent } from "./two-factor-options.component"; +import { TwoFactorOptionsComponentV1 } from "./two-factor-options-v1.component"; const BroadcasterSubscriptionId = "TwoFactorComponent"; @Component({ selector: "app-two-factor", - templateUrl: "two-factor.component.html", + templateUrl: "two-factor-v1.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDestroy { +export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnDestroy { @ViewChild("twoFactorOptions", { read: ViewContainerRef, static: true }) twoFactorOptionsModal: ViewContainerRef; @@ -106,7 +106,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest async anotherMethod() { const [modal, childComponent] = await this.modalService.openViewRef( - TwoFactorOptionsComponent, + TwoFactorOptionsComponentV1, this.twoFactorOptionsModal, ); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 2f4e31204cb..ed55c5fb070 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -652,6 +652,15 @@ "logInToBitwarden": { "message": "Log in to Bitwarden" }, + "enterTheCodeSentToYourEmail": { + "message": "Enter the code sent to your email" + }, + "enterTheCodeFromYourAuthenticatorApp": { + "message": "Enter the code from your authenticator app" + }, + "pressYourYubiKeyToAuthenticate": { + "message": "Press your YubiKey to authenticate" + }, "logInWithPasskey": { "message": "Log in with passkey" }, @@ -825,6 +834,9 @@ "webauthnCancelOrTimeout": { "message": "The authentication was cancelled or took too long. Please try again." }, + "openInNewTab": { + "message": "Open in new tab" + }, "invalidVerificationCode": { "message": "Invalid verification code" }, @@ -855,12 +867,22 @@ "rememberMe": { "message": "Remember me" }, + "dontAskAgainOnThisDeviceFor30Days": { + "message": "Don't ask again on this device for 30 days" + }, "sendVerificationCodeEmailAgain": { "message": "Send verification code email again" }, "useAnotherTwoStepMethod": { "message": "Use another two-step login method" }, + "selectAnotherMethod": { + "message": "Select another method", + "description": "Select another two-step login method" + }, + "useYourRecoveryCode": { + "message": "Use your recovery code" + }, "insertYubiKey": { "message": "Insert your YubiKey into your computer's USB port, then touch its button." }, @@ -927,6 +949,9 @@ "twoStepOptions": { "message": "Two-step login options" }, + "selectTwoStepLoginMethod": { + "message": "Select two-step login method" + }, "selfHostedEnvironment": { "message": "Self-hosted environment" }, @@ -2256,6 +2281,12 @@ "webAuthnAuthenticate": { "message": "Authenticate WebAuthn" }, + "readSecurityKey": { + "message": "Read security key" + }, + "awaitingSecurityKeyInteraction": { + "message": "Awaiting security key interaction..." + }, "hideEmail": { "message": "Hide my email address from recipients." }, @@ -3215,6 +3246,12 @@ "duoRequiredByOrgForAccount": { "message": "Duo two-step login is required for your account." }, + "duoTwoFactorRequiredPageSubtitle": { + "message": "Duo two-step login is required for your account. Follow the steps below to finish logging in." + }, + "followTheStepsBelowToFinishLoggingIn": { + "message": "Follow the steps below to finish logging in." + }, "launchDuo": { "message": "Launch Duo in Browser" }, diff --git a/apps/desktop/src/scss/misc.scss b/apps/desktop/src/scss/misc.scss index 5a9befe2112..885040cc6f9 100644 --- a/apps/desktop/src/scss/misc.scss +++ b/apps/desktop/src/scss/misc.scss @@ -243,14 +243,12 @@ p.lead { } #web-authn-frame { - background: url("../images/loading.svg") 0 0 no-repeat; - height: 250px; - margin: 0 0 15px 0; + height: 40px; iframe { - width: 100%; - height: 100%; border: none; + height: 100%; + width: 100%; } } diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index 6275ad4f4f3..1e8eec759b1 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -3,3 +3,4 @@ export * from "./login-decryption-options"; export * from "./webauthn-login"; export * from "./set-password-jit"; export * from "./registration"; +export * from "./two-factor-auth"; diff --git a/apps/web/src/app/auth/core/services/two-factor-auth/index.ts b/apps/web/src/app/auth/core/services/two-factor-auth/index.ts new file mode 100644 index 00000000000..ba2697fdee4 --- /dev/null +++ b/apps/web/src/app/auth/core/services/two-factor-auth/index.ts @@ -0,0 +1,2 @@ +export * from "./web-two-factor-auth-component.service"; +export * from "./web-two-factor-auth-duo-component.service"; diff --git a/apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-component.service.ts b/apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-component.service.ts new file mode 100644 index 00000000000..451cec57ddd --- /dev/null +++ b/apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-component.service.ts @@ -0,0 +1,14 @@ +import { + DefaultTwoFactorAuthComponentService, + TwoFactorAuthComponentService, + LegacyKeyMigrationAction, +} from "@bitwarden/auth/angular"; + +export class WebTwoFactorAuthComponentService + extends DefaultTwoFactorAuthComponentService + implements TwoFactorAuthComponentService +{ + override determineLegacyKeyMigrationAction(): LegacyKeyMigrationAction { + return LegacyKeyMigrationAction.NAVIGATE_TO_MIGRATION_COMPONENT; + } +} diff --git a/apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-duo-component.service.spec.ts b/apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-duo-component.service.spec.ts new file mode 100644 index 00000000000..8cd92e73267 --- /dev/null +++ b/apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-duo-component.service.spec.ts @@ -0,0 +1,83 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { Duo2faResult } from "@bitwarden/auth/angular"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { WebTwoFactorAuthDuoComponentService } from "./web-two-factor-auth-duo-component.service"; + +describe("WebTwoFactorAuthDuoComponentService", () => { + let webTwoFactorAuthDuoComponentService: WebTwoFactorAuthDuoComponentService; + + let platformUtilsService: MockProxy; + + let mockBroadcastChannel: jest.Mocked; + let eventTarget: EventTarget; + + beforeEach(() => { + jest.clearAllMocks(); + + platformUtilsService = mock(); + + eventTarget = new EventTarget(); + + mockBroadcastChannel = { + name: "duoResult", + postMessage: jest.fn(), + close: jest.fn(), + onmessage: jest.fn(), + onmessageerror: jest.fn(), + addEventListener: jest.fn().mockImplementation((type, listener) => { + eventTarget.addEventListener(type, listener); + }), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + }; + + global.BroadcastChannel = jest.fn(() => mockBroadcastChannel); + + webTwoFactorAuthDuoComponentService = new WebTwoFactorAuthDuoComponentService( + platformUtilsService, + ); + }); + + afterEach(() => { + // reset global object + jest.restoreAllMocks(); + }); + + describe("listenForDuo2faResult$", () => { + it("should return an observable that emits a duo 2FA result when a duo result message is received", (done) => { + const expectedResult: Duo2faResult = { + code: "123456", + state: "verified", + token: "123456|verified", + }; + const mockMessageEvent = new MessageEvent("message", { + data: { + code: "123456", + state: "verified", + }, + lastEventId: "", + origin: "", + ports: [], + source: null, + }); + webTwoFactorAuthDuoComponentService.listenForDuo2faResult$().subscribe((result) => { + expect(result).toEqual(expectedResult); + done(); + }); + + // Trigger the message event + eventTarget.dispatchEvent(mockMessageEvent); + }); + }); + + describe("launchDuoFrameless", () => { + it("should launch the duo frameless URL", async () => { + const duoFramelessUrl = "https://duo.com/frameless"; + await webTwoFactorAuthDuoComponentService.launchDuoFrameless(duoFramelessUrl); + + expect(platformUtilsService.launchUri).toHaveBeenCalledWith(duoFramelessUrl); + }); + }); +}); diff --git a/apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-duo-component.service.ts b/apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-duo-component.service.ts new file mode 100644 index 00000000000..ac8eccb5198 --- /dev/null +++ b/apps/web/src/app/auth/core/services/two-factor-auth/web-two-factor-auth-duo-component.service.ts @@ -0,0 +1,31 @@ +import { fromEvent, map, Observable, share } from "rxjs"; + +import { TwoFactorAuthDuoComponentService, Duo2faResult } from "@bitwarden/auth/angular"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +export class WebTwoFactorAuthDuoComponentService implements TwoFactorAuthDuoComponentService { + private duo2faResult$: Observable; + + constructor(private platformUtilsService: PlatformUtilsService) { + const duoResultChannel: BroadcastChannel = new BroadcastChannel("duoResult"); + + this.duo2faResult$ = fromEvent(duoResultChannel, "message").pipe( + map((msg: MessageEvent) => { + return { + code: msg.data.code, + state: msg.data.state, + token: `${msg.data.code}|${msg.data.state}`, + } as Duo2faResult; + }), + // share the observable so that multiple subscribers can listen to the same event + share(), + ); + } + listenForDuo2faResult$(): Observable { + return this.duo2faResult$; + } + + async launchDuoFrameless(duoFramelessUrl: string): Promise { + this.platformUtilsService.launchUri(duoFramelessUrl); + } +} 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 deleted file mode 100644 index 971c1f3764c..00000000000 --- a/apps/web/src/app/auth/two-factor-auth-duo.component.ts +++ /dev/null @@ -1,77 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { DialogModule } from "@angular/cdk/dialog"; -import { CommonModule } from "@angular/common"; -import { Component, OnDestroy, OnInit } 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"; - -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorAuthDuoComponent as TwoFactorAuthDuoBaseComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { AsyncActionsModule } from "../../../../../libs/components/src/async-actions"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { ButtonModule } from "../../../../../libs/components/src/button"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { FormFieldModule } from "../../../../../libs/components/src/form-field"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { LinkModule } from "../../../../../libs/components/src/link"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -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 - implements OnInit, OnDestroy -{ - 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 deleted file mode 100644 index cbe1d8f0a53..00000000000 --- a/apps/web/src/app/auth/two-factor-auth.component.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { DialogModule } from "@angular/cdk/dialog"; -import { CommonModule } from "@angular/common"; -import { Component, Inject } from "@angular/core"; -import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { ActivatedRoute, Router, RouterLink } from "@angular/router"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { - LinkModule, - TypographyModule, - CheckboxModule, - DialogService, - ToastService, -} from "@bitwarden/components"; - -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorAuthAuthenticatorComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorAuthEmailComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-email.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorAuthWebAuthnComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorAuthYubikeyComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-yubikey.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorAuthComponent as BaseTwoFactorAuthComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TwoFactorOptionsComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-options.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, -} from "../../../../../libs/auth/src/common/abstractions"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { AsyncActionsModule } from "../../../../../libs/components/src/async-actions"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { ButtonModule } from "../../../../../libs/components/src/button"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { FormFieldModule } from "../../../../../libs/components/src/form-field"; - -import { TwoFactorAuthDuoComponent } from "./two-factor-auth-duo.component"; - -@Component({ - standalone: true, - templateUrl: - "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html", - selector: "app-two-factor-auth", - imports: [ - CommonModule, - JslibModule, - DialogModule, - ButtonModule, - LinkModule, - TypographyModule, - ReactiveFormsModule, - FormFieldModule, - AsyncActionsModule, - RouterLink, - CheckboxModule, - TwoFactorOptionsComponent, - TwoFactorAuthEmailComponent, - TwoFactorAuthAuthenticatorComponent, - TwoFactorAuthYubikeyComponent, - TwoFactorAuthDuoComponent, - TwoFactorAuthWebAuthnComponent, - ], - providers: [I18nPipe], -}) -export class TwoFactorAuthComponent extends BaseTwoFactorAuthComponent { - constructor( - protected loginStrategyService: LoginStrategyServiceAbstraction, - protected router: Router, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - environmentService: EnvironmentService, - dialogService: DialogService, - protected route: ActivatedRoute, - logService: LogService, - protected twoFactorService: TwoFactorService, - loginEmailService: LoginEmailServiceAbstraction, - userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - protected ssoLoginService: SsoLoginServiceAbstraction, - protected configService: ConfigService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - accountService: AccountService, - formBuilder: FormBuilder, - @Inject(WINDOW) protected win: Window, - toastService: ToastService, - ) { - super( - loginStrategyService, - router, - i18nService, - platformUtilsService, - environmentService, - dialogService, - route, - logService, - twoFactorService, - loginEmailService, - userDecryptionOptionsService, - ssoLoginService, - configService, - masterPasswordService, - accountService, - formBuilder, - win, - toastService, - ); - this.onSuccessfulLoginNavigate = this.goAfterLogIn; - } - - protected override handleMigrateEncryptionKey(result: AuthResult): boolean { - if (!result.requiresEncryptionKeyMigration) { - return false; - } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["migrate-legacy-encryption"]); - return true; - } -} diff --git a/apps/web/src/app/auth/two-factor-options.component.html b/apps/web/src/app/auth/two-factor-options-v1.component.html similarity index 100% rename from apps/web/src/app/auth/two-factor-options.component.html rename to apps/web/src/app/auth/two-factor-options-v1.component.html diff --git a/apps/web/src/app/auth/two-factor-options.component.ts b/apps/web/src/app/auth/two-factor-options-v1.component.ts similarity index 84% rename from apps/web/src/app/auth/two-factor-options.component.ts rename to apps/web/src/app/auth/two-factor-options-v1.component.ts index fef15799df5..08665dcfcdd 100644 --- a/apps/web/src/app/auth/two-factor-options.component.ts +++ b/apps/web/src/app/auth/two-factor-options-v1.component.ts @@ -2,7 +2,7 @@ import { DialogRef } from "@angular/cdk/dialog"; import { Component } from "@angular/core"; import { Router } from "@angular/router"; -import { TwoFactorOptionsComponent as BaseTwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-options.component"; +import { TwoFactorOptionsComponentV1 as BaseTwoFactorOptionsComponentV1 } from "@bitwarden/angular/auth/components/two-factor-options-v1.component"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -22,9 +22,9 @@ export type TwoFactorOptionsDialogResultType = { @Component({ selector: "app-two-factor-options", - templateUrl: "two-factor-options.component.html", + templateUrl: "two-factor-options-v1.component.html", }) -export class TwoFactorOptionsComponent extends BaseTwoFactorOptionsComponent { +export class TwoFactorOptionsComponentV1 extends BaseTwoFactorOptionsComponentV1 { constructor( twoFactorService: TwoFactorService, router: Router, @@ -47,6 +47,6 @@ export class TwoFactorOptionsComponent extends BaseTwoFactorOptionsComponent { } static open(dialogService: DialogService) { - return dialogService.open(TwoFactorOptionsComponent); + return dialogService.open(TwoFactorOptionsComponentV1); } } diff --git a/apps/web/src/app/auth/two-factor.component.html b/apps/web/src/app/auth/two-factor-v1.component.html similarity index 100% rename from apps/web/src/app/auth/two-factor.component.html rename to apps/web/src/app/auth/two-factor-v1.component.html diff --git a/apps/web/src/app/auth/two-factor.component.ts b/apps/web/src/app/auth/two-factor-v1.component.ts similarity index 93% rename from apps/web/src/app/auth/two-factor.component.ts rename to apps/web/src/app/auth/two-factor-v1.component.ts index eead66468fd..adaa735eca7 100644 --- a/apps/web/src/app/auth/two-factor.component.ts +++ b/apps/web/src/app/auth/two-factor-v1.component.ts @@ -5,7 +5,7 @@ import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { Subject, takeUntil, lastValueFrom } from "rxjs"; -import { TwoFactorComponent as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor.component"; +import { TwoFactorComponentV1 as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor-v1.component"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { LoginStrategyServiceAbstraction, @@ -29,16 +29,16 @@ import { DialogService, ToastService } from "@bitwarden/components"; import { TwoFactorOptionsDialogResult, - TwoFactorOptionsComponent, + TwoFactorOptionsComponentV1, TwoFactorOptionsDialogResultType, -} from "./two-factor-options.component"; +} from "./two-factor-options-v1.component"; @Component({ selector: "app-two-factor", - templateUrl: "two-factor.component.html", + templateUrl: "two-factor-v1.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class TwoFactorComponent extends BaseTwoFactorComponent implements OnInit, OnDestroy { +export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnInit, OnDestroy { @ViewChild("twoFactorOptions", { read: ViewContainerRef, static: true }) twoFactorOptionsModal: ViewContainerRef; formGroup = this.formBuilder.group({ @@ -110,7 +110,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnInit }; async anotherMethod() { - const dialogRef = TwoFactorOptionsComponent.open(this.dialogService); + const dialogRef = TwoFactorOptionsComponentV1.open(this.dialogService); const response: TwoFactorOptionsDialogResultType = await lastValueFrom(dialogRef.closed); if (response.result === TwoFactorOptionsDialogResult.Provider) { this.selectedProviderType = response.type; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 305928bfc24..56f79661b97 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -33,6 +33,8 @@ import { SetPasswordJitService, SsoComponentService, LoginDecryptionOptionsService, + TwoFactorAuthComponentService, + TwoFactorAuthDuoComponentService, } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, @@ -108,6 +110,8 @@ import { WebRegistrationFinishService, WebLoginComponentService, WebLoginDecryptionOptionsService, + WebTwoFactorAuthComponentService, + WebTwoFactorAuthDuoComponentService, } from "../auth"; import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service"; import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service"; @@ -261,6 +265,12 @@ const safeProviders: SafeProvider[] = [ useClass: WebLockComponentService, deps: [], }), + // TODO: PM-18182 - Refactor component services into lazy loaded modules + safeProvider({ + provide: TwoFactorAuthComponentService, + useClass: WebTwoFactorAuthComponentService, + deps: [], + }), safeProvider({ provide: SetPasswordJitService, useClass: WebSetPasswordJitService, @@ -328,6 +338,11 @@ const safeProviders: SafeProvider[] = [ useClass: WebSsoComponentService, deps: [I18nServiceAbstraction], }), + safeProvider({ + provide: TwoFactorAuthDuoComponentService, + useClass: WebTwoFactorAuthDuoComponentService, + deps: [PlatformUtilsService], + }), safeProvider({ provide: LoginDecryptionOptionsService, useClass: WebLoginDecryptionOptionsService, diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 554f1f62e24..81057426500 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -12,7 +12,6 @@ import { activeAuthGuard, } from "@bitwarden/angular/auth/guards"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; -import { twofactorRefactorSwap } from "@bitwarden/angular/utils/two-factor-component-refactor-route-swap"; import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards"; import { AnonLayoutWrapperComponent, @@ -38,6 +37,8 @@ import { SsoComponent, VaultIcon, LoginDecryptionOptionsComponent, + TwoFactorAuthComponent, + TwoFactorAuthGuard, NewDeviceVerificationComponent, DeviceVerificationIcon, } from "@bitwarden/auth/angular"; @@ -71,8 +72,7 @@ import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emerg import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component"; import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module"; import { SsoComponentV1 } from "./auth/sso-v1.component"; -import { TwoFactorAuthComponent } from "./auth/two-factor-auth.component"; -import { TwoFactorComponent } from "./auth/two-factor.component"; +import { TwoFactorComponentV1 } from "./auth/two-factor-v1.component"; import { UpdatePasswordComponent } from "./auth/update-password.component"; import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "./auth/verify-email-token.component"; @@ -505,6 +505,50 @@ const routes: Routes = [ }, }, }, + ...unauthUiRefreshSwap( + TwoFactorComponentV1, + TwoFactorAuthComponent, + { + path: "2fa", + canActivate: [unauthGuardFn()], + children: [ + { + path: "", + component: TwoFactorComponentV1, + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + data: { + pageTitle: { + key: "verifyIdentity", + }, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, + { + path: "2fa", + canActivate: [unauthGuardFn(), TwoFactorAuthGuard], + children: [ + { + path: "", + component: TwoFactorAuthComponent, + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + data: { + pageTitle: { + key: "verifyIdentity", + }, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, + ), { path: "lock", canActivate: [deepLinkGuard(), lockGuard()], @@ -522,25 +566,6 @@ const routes: Routes = [ showReadonlyHostname: true, } satisfies AnonLayoutWrapperData, }, - { - path: "2fa", - canActivate: [unauthGuardFn()], - children: [ - ...twofactorRefactorSwap(TwoFactorComponent, TwoFactorAuthComponent, { - path: "", - }), - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - data: { - pageTitle: { - key: "verifyIdentity", - }, - } satisfies RouteDataProperties & AnonLayoutWrapperData, - }, { path: "authentication-timeout", canActivate: [unauthGuardFn()], diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 5597123b24a..bec54cf2c54 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -44,8 +44,8 @@ import { TwoFactorSetupComponent } from "../auth/settings/two-factor/two-factor- import { TwoFactorVerifyComponent } from "../auth/settings/two-factor/two-factor-verify.component"; import { UserVerificationModule } from "../auth/shared/components/user-verification"; import { SsoComponentV1 } from "../auth/sso-v1.component"; -import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component"; -import { TwoFactorComponent } from "../auth/two-factor.component"; +import { TwoFactorOptionsComponentV1 } from "../auth/two-factor-options-v1.component"; +import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component"; import { UpdatePasswordComponent } from "../auth/update-password.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component"; @@ -152,12 +152,12 @@ import { SharedModule } from "./shared.module"; SetPasswordComponent, SponsoredFamiliesComponent, SponsoringOrgRowComponent, + TwoFactorComponentV1, SsoComponentV1, TwoFactorSetupAuthenticatorComponent, - TwoFactorComponent, TwoFactorSetupDuoComponent, TwoFactorSetupEmailComponent, - TwoFactorOptionsComponent, + TwoFactorOptionsComponentV1, TwoFactorRecoveryComponent, TwoFactorSetupComponent, TwoFactorVerifyComponent, @@ -216,19 +216,18 @@ import { SharedModule } from "./shared.module"; SetPasswordComponent, SponsoredFamiliesComponent, SponsoringOrgRowComponent, + TwoFactorComponentV1, SsoComponentV1, TwoFactorSetupAuthenticatorComponent, - TwoFactorComponent, TwoFactorSetupDuoComponent, TwoFactorSetupEmailComponent, - TwoFactorOptionsComponent, - TwoFactorRecoveryComponent, + TwoFactorOptionsComponentV1, TwoFactorSetupComponent, TwoFactorVerifyComponent, TwoFactorSetupWebAuthnComponent, TwoFactorSetupYubiKeyComponent, - UpdatePasswordComponent, UpdateTempPasswordComponent, + UpdatePasswordComponent, UserLayoutComponent, VerifyEmailTokenComponent, VerifyRecoverDeleteComponent, diff --git a/apps/web/src/connectors/webauthn-fallback.html b/apps/web/src/connectors/webauthn-fallback.html index 19d0a6a5eda..662eb0b6baf 100644 --- a/apps/web/src/connectors/webauthn-fallback.html +++ b/apps/web/src/connectors/webauthn-fallback.html @@ -1,39 +1,132 @@ - + + + + Bitwarden WebAuthn Connector - -
-
-
- -
-

- -

-
-
-
-

-
- - -
-
-

- -

-
+ +
+ + Bitwarden + + +
+
+ + + + + + + + + + + + + + +
+ +
+ +

+ +

+
+ +
+
+ +
+
+

+ +
+ +
+ +
-
+ diff --git a/apps/web/src/connectors/webauthn-fallback.ts b/apps/web/src/connectors/webauthn-fallback.ts index 6f32bbaecf8..5410b89dcfa 100644 --- a/apps/web/src/connectors/webauthn-fallback.ts +++ b/apps/web/src/connectors/webauthn-fallback.ts @@ -4,10 +4,6 @@ import { b64Decode, getQsParam } from "./common"; import { buildDataString, parseWebauthnJson } from "./common-webauthn"; import { TranslationService } from "./translation.service"; -// FIXME: Remove when updating file. Eslint update -// eslint-disable-next-line @typescript-eslint/no-require-imports -require("./webauthn.scss"); - let parsed = false; let webauthnJson: any; let parentUrl: string = null; @@ -75,17 +71,22 @@ document.addEventListener("DOMContentLoaded", async () => { await localeService.init(); - document.getElementById("msg").innerText = localeService.t("webAuthnFallbackMsg"); - document.getElementById("remember-label").innerText = localeService.t("rememberMe"); + document.getElementById("remember-label").innerText = localeService.t( + "dontAskAgainOnThisDeviceFor30Days", + ); const button = document.getElementById("webauthn-button"); - button.innerText = localeService.t("webAuthnAuthenticate"); + button.innerText = localeService.t("readSecurityKey"); button.onclick = start; - document.getElementById("spinner").classList.add("d-none"); - const content = document.getElementById("content"); - content.classList.add("d-block"); - content.classList.remove("d-none"); + const titleForSmallerScreens = document.getElementById("title-smaller-screens"); + const titleForLargerScreens = document.getElementById("title-larger-screens"); + + titleForSmallerScreens.innerText = localeService.t("verifyIdentity"); + titleForLargerScreens.innerText = localeService.t("verifyIdentity"); + + const subtitle = document.getElementById("subtitle"); + subtitle.innerText = localeService.t("followTheStepsBelowToFinishLoggingIn"); }); function start() { @@ -146,20 +147,24 @@ function error(message: string) { el.textContent = message; el.classList.add("alert"); el.classList.add("alert-danger"); + el.classList.remove("tw-hidden"); } function success(message: string) { (document.getElementById("webauthn-button") as HTMLButtonElement).disabled = true; + (document.getElementById("remember") as HTMLInputElement).disabled = true; const el = document.getElementById("msg"); resetMsgBox(el); el.textContent = message; el.classList.add("alert"); el.classList.add("alert-success"); + el.classList.remove("tw-hidden"); } function resetMsgBox(el: HTMLElement) { el.classList.remove("alert"); el.classList.remove("alert-danger"); el.classList.remove("alert-success"); + el.classList.add("tw-hidden"); } diff --git a/apps/web/src/connectors/webauthn.html b/apps/web/src/connectors/webauthn.html index cc46e4932af..27f143f90d3 100644 --- a/apps/web/src/connectors/webauthn.html +++ b/apps/web/src/connectors/webauthn.html @@ -5,14 +5,11 @@ Bitwarden WebAuthn Connector - - - - - - -
- -
+ + diff --git a/apps/web/src/connectors/webauthn.ts b/apps/web/src/connectors/webauthn.ts index 32e6e0ab673..dd38bdfb040 100644 --- a/apps/web/src/connectors/webauthn.ts +++ b/apps/web/src/connectors/webauthn.ts @@ -13,6 +13,7 @@ let parsed = false; let webauthnJson: any; let headerText: string = null; let btnText: string = null; +let btnAwaitingInteractionText: string = null; let btnReturnText: string = null; let parentUrl: string = null; let parentOrigin: string = null; @@ -21,21 +22,60 @@ let stopWebAuthn = false; let sentSuccess = false; let obj: any = null; +// For accessibility, we do not actually disable the button as it would +// become unfocusable by a screenreader. We just make it look disabled. +const disabledBtnClasses = [ + "tw-bg-secondary-300", + "tw-border-secondary-300", + "!tw-text-muted", + "!tw-cursor-not-allowed", + "hover:tw-bg-secondary-300", + "hover:tw-border-secondary-300", + "hover:!tw-text-muted", + "hover:tw-no-underline", +]; + +const enabledBtnClasses = [ + "tw-bg-primary-600", + "tw-border-primary-600", + "!tw-text-contrast", + "hover:tw-bg-primary-700", + "hover:tw-border-primary-700", + "hover:!tw-text-contrast", + "hover:tw-no-underline", +]; + document.addEventListener("DOMContentLoaded", () => { init(); - - parseParameters(); - if (headerText) { - const header = document.getElementById("webauthn-header"); - header.innerText = decodeURI(headerText); - } - if (btnText) { - const button = document.getElementById("webauthn-button"); - button.innerText = decodeURI(btnText); - button.onclick = executeWebAuthn; - } }); +function setDefaultWebAuthnButtonState() { + if (!btnText) { + return; + } + + const button = document.getElementById("webauthn-button"); + button.onclick = executeWebAuthn; + + button.innerText = decodeURI(btnText); + + // reset back to default button state + button.classList.remove(...disabledBtnClasses); + button.classList.add(...enabledBtnClasses); +} + +function setAwaitingInteractionWebAuthnButtonState() { + if (!btnAwaitingInteractionText) { + return; + } + const button = document.getElementById("webauthn-button"); + button.innerText = decodeURI(btnAwaitingInteractionText); + button.onclick = null; + + button.classList.remove(...enabledBtnClasses); + button.classList.add(...disabledBtnClasses); +} + function init() { start(); onMessage(); @@ -76,6 +116,7 @@ function parseParametersV1() { webauthnJson = b64Decode(data); headerText = getQsParam("headerText"); btnText = getQsParam("btnText"); + btnAwaitingInteractionText = getQsParam("btnAwaitingInteractionText"); btnReturnText = getQsParam("btnReturnText"); } @@ -113,6 +154,14 @@ function start() { } parseParameters(); + + if (headerText) { + const header = document.getElementById("webauthn-header"); + header.innerText = decodeURI(headerText); + } + + setDefaultWebAuthnButtonState(); + if (!webauthnJson) { error("No data."); return; @@ -141,9 +190,12 @@ function start() { function executeWebAuthn() { if (stopWebAuthn) { + // reset back to default button state + setDefaultWebAuthnButtonState(); return; } + setAwaitingInteractionWebAuthnButtonState(); navigator.credentials.get({ publicKey: obj }).then(success).catch(error); } @@ -156,6 +208,7 @@ function onMessage() { } if (event.data === "stop") { + setDefaultWebAuthnButtonState(); stopWebAuthn = true; } else if (event.data === "start" && stopWebAuthn) { start(); @@ -171,6 +224,7 @@ function error(message: string) { returnButton(mobileCallbackUri + "?error=" + encodeURIComponent(message)); } else { parent.postMessage("error|" + message, parentUrl); + setDefaultWebAuthnButtonState(); } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index f94f99ec43a..b6fb8279a72 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1182,6 +1182,15 @@ "logInToBitwarden": { "message": "Log in to Bitwarden" }, + "enterTheCodeSentToYourEmail": { + "message": "Enter the code sent to your email" + }, + "enterTheCodeFromYourAuthenticatorApp": { + "message": "Enter the code from your authenticator app" + }, + "pressYourYubiKeyToAuthenticate": { + "message": "Press your YubiKey to authenticate" + }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1464,12 +1473,22 @@ "rememberMe": { "message": "Remember me" }, + "dontAskAgainOnThisDeviceFor30Days": { + "message": "Don't ask again on this device for 30 days" + }, "sendVerificationCodeEmailAgain": { "message": "Send verification code email again" }, "useAnotherTwoStepMethod": { "message": "Use another two-step login method" }, + "selectAnotherMethod": { + "message": "Select another method", + "description": "Select another two-step login method" + }, + "useYourRecoveryCode": { + "message": "Use your recovery code" + }, "insertYubiKey": { "message": "Insert your YubiKey into your computer's USB port, then touch its button." }, @@ -1488,6 +1507,9 @@ "twoStepOptions": { "message": "Two-step login options" }, + "selectTwoStepLoginMethod": { + "message": "Select two-step login method" + }, "recoveryCodeDesc": { "message": "Lost access to all of your two-step login providers? Use your recovery code to turn off all two-step login providers from your account." }, @@ -1530,6 +1552,9 @@ "webAuthnMigrated": { "message": "(Migrated from FIDO)" }, + "openInNewTab": { + "message": "Open in new tab" + }, "emailTitle": { "message": "Email" }, @@ -5683,6 +5708,12 @@ "webAuthnAuthenticate": { "message": "Authenticate WebAuthn" }, + "readSecurityKey": { + "message": "Read security key" + }, + "awaitingSecurityKeyInteraction": { + "message": "Awaiting security key interaction..." + }, "webAuthnNotSupported": { "message": "WebAuthn is not supported in this browser." }, @@ -7229,6 +7260,12 @@ "duoRequiredByOrgForAccount": { "message": "Duo two-step login is required for your account." }, + "duoTwoFactorRequiredPageSubtitle": { + "message": "Duo two-step login is required for your account. Follow the steps below to finish logging in." + }, + "followTheStepsBelowToFinishLoggingIn": { + "message": "Follow the steps below to finish logging in." + }, "launchDuo": { "message": "Launch Duo" }, diff --git a/apps/web/src/scss/plugins.scss b/apps/web/src/scss/plugins.scss index 5fbd32ac4ee..ad996b0efaf 100644 --- a/apps/web/src/scss/plugins.scss +++ b/apps/web/src/scss/plugins.scss @@ -12,7 +12,7 @@ } #web-authn-frame { - height: 315px; + height: 40px; @include themify($themes) { background: themed("imgLoading") 0 0 no-repeat; } diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index f63ae6463f3..28fe5ce1f35 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -106,7 +106,7 @@ const plugins = [ new HtmlWebpackPlugin({ template: "./src/connectors/webauthn.html", filename: "webauthn-connector.html", - chunks: ["connectors/webauthn"], + chunks: ["connectors/webauthn", "styles"], }), new HtmlWebpackPlugin({ template: "./src/connectors/webauthn-mobile.html", @@ -116,7 +116,7 @@ const plugins = [ new HtmlWebpackPlugin({ template: "./src/connectors/webauthn-fallback.html", filename: "webauthn-fallback-connector.html", - chunks: ["connectors/webauthn-fallback"], + chunks: ["connectors/webauthn-fallback", "styles"], }), new HtmlWebpackPlugin({ template: "./src/connectors/sso.html", diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.html deleted file mode 100644 index e738b1eb8c1..00000000000 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.html +++ /dev/null @@ -1,16 +0,0 @@ - -

- {{ "enterVerificationCodeApp" | i18n }} -

- - {{ "verificationCode" | i18n }} - - -
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 deleted file mode 100644 index 34b7ee90395..00000000000 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.html +++ /dev/null @@ -1,6 +0,0 @@ - -

- {{ "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 deleted file mode 100644 index 3131cc042f7..00000000000 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-duo.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { DialogModule } from "@angular/cdk/dialog"; -import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, Input, OnInit, 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 implements OnInit { - @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-email.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-email.component.html deleted file mode 100644 index c9d0901bcae..00000000000 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-email.component.html +++ /dev/null @@ -1,19 +0,0 @@ -

- {{ "enterVerificationCodeEmail" | i18n: twoFactorEmail }} -

- - {{ "verificationCode" | i18n }} - - - - {{ "sendVerificationCodeEmailAgain" | i18n }} - - diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.html deleted file mode 100644 index 65a7ef9a50e..00000000000 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- -
- -
-

{{ "webAuthnNewTab" | i18n }}

- -
-
diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-yubikey.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-yubikey.component.html deleted file mode 100644 index c6654c00edb..00000000000 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-yubikey.component.html +++ /dev/null @@ -1,17 +0,0 @@ -

{{ "insertYubiKey" | i18n }}

- - - - - - - {{ "verificationCode" | i18n }} - - 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 deleted file mode 100644 index 087ecd2764e..00000000000 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html +++ /dev/null @@ -1,76 +0,0 @@ -
- - - - - - - {{ "rememberMe" | i18n }} - - - -

{{ "noTwoStepProviders" | i18n }}

-

{{ "noTwoStepProviders2" | i18n }}

-
-
- -
- -
- - - - - {{ "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 deleted file mode 100644 index 3e59e4a29b9..00000000000 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts +++ /dev/null @@ -1,414 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { CommonModule } from "@angular/common"; -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"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; -import { - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, - TrustedDeviceUserDecryptionOption, - UserDecryptionOptions, -} from "@bitwarden/auth/common"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; -import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; -import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; -import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; -import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { - AsyncActionsModule, - ButtonModule, - DialogService, - FormFieldModule, - ToastService, -} from "@bitwarden/components"; - -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"; -import { - TwoFactorOptionsDialogResult, - TwoFactorOptionsComponent, - TwoFactorOptionsDialogResultType, -} from "./two-factor-options.component"; - -@Component({ - standalone: true, - selector: "app-two-factor-auth", - templateUrl: "two-factor-auth.component.html", - imports: [ - CommonModule, - JslibModule, - ReactiveFormsModule, - FormFieldModule, - AsyncActionsModule, - RouterLink, - ButtonModule, - TwoFactorOptionsComponent, - TwoFactorAuthAuthenticatorComponent, - TwoFactorAuthEmailComponent, - TwoFactorAuthDuoComponent, - TwoFactorAuthYubikeyComponent, - TwoFactorAuthWebAuthnComponent, - ], - providers: [I18nPipe], -}) -export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements OnInit { - token = ""; - remember = false; - orgIdentifier: string = null; - - providers = TwoFactorProviders; - providerType = TwoFactorProviderType; - selectedProviderType: TwoFactorProviderType = TwoFactorProviderType.Authenticator; - providerData: any; - - @ViewChild("duoComponent") duoComponent!: TwoFactorAuthDuoComponent; - formGroup = this.formBuilder.group({ - token: [ - "", - { - validators: [Validators.required], - updateOn: "submit", - }, - ], - remember: [false], - }); - actionButtonText = ""; - title = ""; - formPromise: Promise; - - private destroy$ = new Subject(); - - onSuccessfulLogin: () => Promise; - onSuccessfulLoginNavigate: () => Promise; - - onSuccessfulLoginTde: () => Promise; - onSuccessfulLoginTdeNavigate: () => Promise; - - submitForm = async () => { - await this.submit(); - }; - goAfterLogIn = async () => { - this.loginEmailService.clearValues(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate([this.successRoute], { - queryParams: { - identifier: this.orgIdentifier, - }, - }); - }; - - protected loginRoute = "login"; - - protected trustedDeviceEncRoute = "login-initiated"; - protected changePasswordRoute = "set-password"; - protected forcePasswordResetRoute = "update-temp-password"; - protected successRoute = "vault"; - - constructor( - protected loginStrategyService: LoginStrategyServiceAbstraction, - protected router: Router, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - environmentService: EnvironmentService, - private dialogService: DialogService, - protected route: ActivatedRoute, - private logService: LogService, - protected twoFactorService: TwoFactorService, - private loginEmailService: LoginEmailServiceAbstraction, - private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - protected ssoLoginService: SsoLoginServiceAbstraction, - protected configService: ConfigService, - private masterPasswordService: InternalMasterPasswordServiceAbstraction, - private accountService: AccountService, - private formBuilder: FormBuilder, - @Inject(WINDOW) protected win: Window, - protected toastService: ToastService, - ) { - super(environmentService, i18nService, platformUtilsService, toastService); - } - - async ngOnInit() { - if (!(await this.authing()) || (await this.twoFactorService.getProviders()) == null) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate([this.loginRoute]); - return; - } - - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - this.route.queryParams.pipe(first()).subscribe((qParams) => { - if (qParams.identifier != null) { - this.orgIdentifier = qParams.identifier; - } - }); - - if (await this.needsLock()) { - this.successRoute = "lock"; - } - - const webAuthnSupported = this.platformUtilsService.supportsWebAuthn(this.win); - this.selectedProviderType = await this.twoFactorService.getDefaultProvider(webAuthnSupported); - const providerData = await this.twoFactorService.getProviders().then((providers) => { - return providers.get(this.selectedProviderType); - }); - this.providerData = providerData; - await this.updateUIToProviderData(); - - this.actionButtonText = this.i18nService.t("continue"); - this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { - this.token = value.token; - this.remember = value.remember; - }); - } - - async submit() { - await this.setupCaptcha(); - - if (this.token == null || this.token === "") { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("verificationCodeRequired"), - }); - return; - } - - try { - this.formPromise = this.loginStrategyService.logInTwoFactor( - new TokenTwoFactorRequest(this.selectedProviderType, this.token, this.remember), - this.captchaToken, - ); - const authResult: AuthResult = await this.formPromise; - this.logService.info("Successfully submitted two factor token"); - await this.handleLoginResponse(authResult); - } catch { - this.logService.error("Error submitting two factor token"); - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("invalidVerificationCode"), - }); - } - } - - async selectOtherTwoFactorMethod() { - const dialogRef = TwoFactorOptionsComponent.open(this.dialogService); - const response: TwoFactorOptionsDialogResultType = await lastValueFrom(dialogRef.closed); - if (response.result === TwoFactorOptionsDialogResult.Provider) { - const providerData = await this.twoFactorService.getProviders().then((providers) => { - return providers.get(response.type); - }); - this.providerData = providerData; - this.selectedProviderType = response.type; - await this.updateUIToProviderData(); - } - } - - async launchDuo() { - if (this.duoComponent != null) { - await this.duoComponent.launchDuoFrameless(); - } - } - - protected handleMigrateEncryptionKey(result: AuthResult): boolean { - if (!result.requiresEncryptionKeyMigration) { - return false; - } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["migrate-legacy-encryption"]); - return true; - } - - async updateUIToProviderData() { - if (this.selectedProviderType == null) { - this.title = this.i18nService.t("loginUnavailable"); - return; - } - - this.title = (TwoFactorProviders as any)[this.selectedProviderType].name; - } - - private async handleLoginResponse(authResult: AuthResult) { - if (this.handleCaptchaRequired(authResult)) { - return; - } else if (this.handleMigrateEncryptionKey(authResult)) { - return; - } - - // Save off the OrgSsoIdentifier for use in the TDE flows - // - TDE login decryption options component - // - Browser SSO on extension open - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier, userId); - this.loginEmailService.clearValues(); - - // note: this flow affects both TDE & standard users - if (this.isForcePasswordResetRequired(authResult)) { - return await this.handleForcePasswordReset(this.orgIdentifier); - } - - const userDecryptionOpts = await firstValueFrom( - this.userDecryptionOptionsService.userDecryptionOptions$, - ); - - const tdeEnabled = await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption); - - if (tdeEnabled) { - return await this.handleTrustedDeviceEncryptionEnabled( - authResult, - this.orgIdentifier, - userDecryptionOpts, - ); - } - - // User must set password if they don't have one and they aren't using either TDE or key connector. - const requireSetPassword = - !userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined; - - if (requireSetPassword || authResult.resetMasterPassword) { - // Change implies going no password -> password in this case - return await this.handleChangePasswordRequired(this.orgIdentifier); - } - - return await this.handleSuccessfulLogin(); - } - - private async isTrustedDeviceEncEnabled( - trustedDeviceOption: TrustedDeviceUserDecryptionOption, - ): Promise { - const ssoTo2faFlowActive = this.route.snapshot.queryParamMap.get("sso") === "true"; - - return ssoTo2faFlowActive && trustedDeviceOption !== undefined; - } - - private async handleTrustedDeviceEncryptionEnabled( - authResult: AuthResult, - orgIdentifier: string, - userDecryptionOpts: UserDecryptionOptions, - ): Promise { - // If user doesn't have a MP, but has reset password permission, they must set a MP - if ( - !userDecryptionOpts.hasMasterPassword && - userDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission - ) { - // Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device) - // Note: we cannot directly navigate to the set password screen in this scenario as we are in a pre-decryption state, and - // if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key. - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - await this.masterPasswordService.setForceSetPasswordReason( - ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, - userId, - ); - } - - if (this.onSuccessfulLoginTde != null) { - // Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete - // before navigating to the success route. - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.onSuccessfulLoginTde(); - } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.navigateViaCallbackOrRoute( - this.onSuccessfulLoginTdeNavigate, - // Navigate to TDE page (if user was on trusted device and TDE has decrypted - // their user key, the login-initiated guard will redirect them to the vault) - [this.trustedDeviceEncRoute], - ); - } - - private async handleChangePasswordRequired(orgIdentifier: string) { - await this.router.navigate([this.changePasswordRoute], { - queryParams: { - identifier: orgIdentifier, - }, - }); - } - - /** - * Determines if a user needs to reset their password based on certain conditions. - * Users can be forced to reset their password via an admin or org policy disallowing weak passwords. - * Note: this is different from the SSO component login flow as a user can - * login with MP and then have to pass 2FA to finish login and we can actually - * evaluate if they have a weak password at that time. - * - * @param {AuthResult} authResult - The authentication result. - * @returns {boolean} Returns true if a password reset is required, false otherwise. - */ - private isForcePasswordResetRequired(authResult: AuthResult): boolean { - const forceResetReasons = [ - ForceSetPasswordReason.AdminForcePasswordReset, - ForceSetPasswordReason.WeakMasterPassword, - ]; - - return forceResetReasons.includes(authResult.forcePasswordReset); - } - - private async handleForcePasswordReset(orgIdentifier: string) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate([this.forcePasswordResetRoute], { - queryParams: { - identifier: orgIdentifier, - }, - }); - } - - private async handleSuccessfulLogin() { - if (this.onSuccessfulLogin != null) { - // Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete - // before navigating to the success route. - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.onSuccessfulLogin(); - } - await this.navigateViaCallbackOrRoute(this.onSuccessfulLoginNavigate, [this.successRoute]); - } - - private async navigateViaCallbackOrRoute( - callback: () => Promise, - commands: unknown[], - extras?: NavigationExtras, - ): Promise { - if (callback) { - await callback(); - } else { - await this.router.navigate(commands, extras); - } - } - - private async authing(): Promise { - return (await firstValueFrom(this.loginStrategyService.currentAuthType$)) !== null; - } - - private async needsLock(): Promise { - const authType = await firstValueFrom(this.loginStrategyService.currentAuthType$); - return authType == AuthenticationType.Sso || authType == AuthenticationType.UserApiKey; - } -} diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-options.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-options.component.html deleted file mode 100644 index bd19c4cd0bf..00000000000 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-options.component.html +++ /dev/null @@ -1,51 +0,0 @@ - - - {{ "twoStepOptions" | i18n }} - - -
-
-
- -
-
-

{{ p.name }}

-

{{ p.description }}

-
-
- -
-
-
-
-
-
-
- rc logo -
-
-

{{ "recoveryCodeTitle" | i18n }}

-

{{ "recoveryCodeDesc" | i18n }}

-
-
- -
-
-
-
- - - -
diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-options.component.ts b/libs/angular/src/auth/components/two-factor-auth/two-factor-options.component.ts deleted file mode 100644 index 61f225cc21c..00000000000 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-options.component.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { DialogRef } from "@angular/cdk/dialog"; -import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, OnInit, Output } from "@angular/core"; -import { firstValueFrom } from "rxjs"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; -import { ClientType } from "@bitwarden/common/enums"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ButtonModule, DialogModule, DialogService, TypographyModule } from "@bitwarden/components"; - -export enum TwoFactorOptionsDialogResult { - Provider = "Provider selected", - Recover = "Recover selected", -} - -export type TwoFactorOptionsDialogResultType = { - result: TwoFactorOptionsDialogResult; - type: TwoFactorProviderType; -}; - -@Component({ - standalone: true, - selector: "app-two-factor-options", - templateUrl: "two-factor-options.component.html", - imports: [CommonModule, JslibModule, DialogModule, ButtonModule, TypographyModule], - providers: [I18nPipe], -}) -export class TwoFactorOptionsComponent implements OnInit { - @Output() onProviderSelected = new EventEmitter(); - @Output() onRecoverSelected = new EventEmitter(); - - providers: any[] = []; - - // todo: remove after porting to two-factor-options-v2 - // icons cause the layout to break on browser extensions - areIconsDisabled = false; - - constructor( - private twoFactorService: TwoFactorService, - private environmentService: EnvironmentService, - private dialogRef: DialogRef, - private platformUtilsService: PlatformUtilsService, - ) { - // todo: remove after porting to two-factor-options-v2 - if (this.platformUtilsService.getClientType() == ClientType.Browser) { - this.areIconsDisabled = true; - } - } - - async ngOnInit() { - this.providers = await this.twoFactorService.getSupportedProviders(window); - } - - async choose(p: any) { - this.onProviderSelected.emit(p.type); - this.dialogRef.close({ result: TwoFactorOptionsDialogResult.Provider, type: p.type }); - } - - async recover() { - const env = await firstValueFrom(this.environmentService.environment$); - const webVault = env.getWebVaultUrl(); - this.platformUtilsService.launchUri(webVault + "/#/recover-2fa"); - this.onRecoverSelected.emit(); - this.dialogRef.close({ result: TwoFactorOptionsDialogResult.Recover }); - } - - static open(dialogService: DialogService) { - return dialogService.open(TwoFactorOptionsComponent); - } -} diff --git a/libs/angular/src/auth/components/two-factor-options.component.ts b/libs/angular/src/auth/components/two-factor-options-v1.component.ts similarity index 96% rename from libs/angular/src/auth/components/two-factor-options.component.ts rename to libs/angular/src/auth/components/two-factor-options-v1.component.ts index 1bbf81fa34f..f02eabcc156 100644 --- a/libs/angular/src/auth/components/two-factor-options.component.ts +++ b/libs/angular/src/auth/components/two-factor-options-v1.component.ts @@ -12,7 +12,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @Directive() -export class TwoFactorOptionsComponent implements OnInit { +export class TwoFactorOptionsComponentV1 implements OnInit { @Output() onProviderSelected = new EventEmitter(); @Output() onRecoverSelected = new EventEmitter(); diff --git a/libs/angular/src/auth/components/two-factor.component.spec.ts b/libs/angular/src/auth/components/two-factor-v1.component.spec.ts similarity index 99% rename from libs/angular/src/auth/components/two-factor.component.spec.ts rename to libs/angular/src/auth/components/two-factor-v1.component.spec.ts index 414aa1dc2a3..82dea7cd8c0 100644 --- a/libs/angular/src/auth/components/two-factor.component.spec.ts +++ b/libs/angular/src/auth/components/two-factor-v1.component.spec.ts @@ -34,11 +34,11 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp import { UserId } from "@bitwarden/common/types/guid"; import { ToastService } from "@bitwarden/components"; -import { TwoFactorComponent } from "./two-factor.component"; +import { TwoFactorComponentV1 } from "./two-factor-v1.component"; // test component that extends the TwoFactorComponent @Component({}) -class TestTwoFactorComponent extends TwoFactorComponent {} +class TestTwoFactorComponent extends TwoFactorComponentV1 {} interface TwoFactorComponentProtected { trustedDeviceEncRoute: string; diff --git a/libs/angular/src/auth/components/two-factor.component.ts b/libs/angular/src/auth/components/two-factor-v1.component.ts similarity index 99% rename from libs/angular/src/auth/components/two-factor.component.ts rename to libs/angular/src/auth/components/two-factor-v1.component.ts index e43797332ec..4cbaa9362f2 100644 --- a/libs/angular/src/auth/components/two-factor.component.ts +++ b/libs/angular/src/auth/components/two-factor-v1.component.ts @@ -40,7 +40,7 @@ import { ToastService } from "@bitwarden/components"; import { CaptchaProtectedComponent } from "./captcha-protected.component"; @Directive() -export class TwoFactorComponent extends CaptchaProtectedComponent implements OnInit, OnDestroy { +export class TwoFactorComponentV1 extends CaptchaProtectedComponent implements OnInit, OnDestroy { token = ""; remember = false; webAuthnReady = false; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index d6db49c109d..10f3f32fb85 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -20,6 +20,12 @@ import { DefaultLoginComponentService, LoginDecryptionOptionsService, DefaultLoginDecryptionOptionsService, + TwoFactorAuthComponentService, + DefaultTwoFactorAuthComponentService, + DefaultTwoFactorAuthEmailComponentService, + TwoFactorAuthEmailComponentService, + DefaultTwoFactorAuthWebAuthnComponentService, + TwoFactorAuthWebAuthnComponentService, DefaultLoginApprovalComponentService, } from "@bitwarden/auth/angular"; import { @@ -1365,6 +1371,21 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultRegistrationFinishService, deps: [KeyService, AccountApiServiceAbstraction], }), + safeProvider({ + provide: TwoFactorAuthComponentService, + useClass: DefaultTwoFactorAuthComponentService, + deps: [], + }), + safeProvider({ + provide: TwoFactorAuthWebAuthnComponentService, + useClass: DefaultTwoFactorAuthWebAuthnComponentService, + deps: [], + }), + safeProvider({ + provide: TwoFactorAuthEmailComponentService, + useClass: DefaultTwoFactorAuthEmailComponentService, + deps: [], + }), safeProvider({ provide: ViewCacheService, useExisting: NoopViewCacheService, diff --git a/libs/angular/src/utils/two-factor-component-refactor-route-swap.ts b/libs/angular/src/utils/two-factor-component-refactor-route-swap.ts deleted file mode 100644 index 8b57a3eb94f..00000000000 --- a/libs/angular/src/utils/two-factor-component-refactor-route-swap.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Type, inject } from "@angular/core"; -import { Route, Routes } from "@angular/router"; - -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; - -import { componentRouteSwap } from "./component-route-swap"; -/** - * Helper function to swap between two components based on the TwoFactorComponentRefactor feature flag. - * @param defaultComponent - The current non-refactored component to render. - * @param refreshedComponent - The new refactored component to render. - * @param defaultOptions - The options to apply to the default component and the refactored component, if alt options are not provided. - * @param altOptions - The options to apply to the refactored component. - */ -export function twofactorRefactorSwap( - defaultComponent: Type, - refreshedComponent: Type, - defaultOptions: Route, - altOptions?: Route, -): Routes { - return componentRouteSwap( - defaultComponent, - refreshedComponent, - async () => { - const configService = inject(ConfigService); - return configService.getFeatureFlag(FeatureFlag.TwoFactorComponentRefactor); - }, - defaultOptions, - altOptions, - ); -} diff --git a/libs/auth/src/angular/icons/two-factor-auth/index.ts b/libs/auth/src/angular/icons/two-factor-auth/index.ts new file mode 100644 index 00000000000..d5ac187a5b9 --- /dev/null +++ b/libs/auth/src/angular/icons/two-factor-auth/index.ts @@ -0,0 +1,6 @@ +export * from "./two-factor-auth-authenticator.icon"; +export * from "./two-factor-auth-email.icon"; +export * from "./two-factor-auth-webauthn.icon"; +export * from "./two-factor-auth-security-key.icon"; +export * from "./two-factor-auth-duo.icon"; +export * from "./two-factor-auth-yubico.icon"; diff --git a/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-authenticator.icon.ts b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-authenticator.icon.ts new file mode 100644 index 00000000000..daef1f94dca --- /dev/null +++ b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-authenticator.icon.ts @@ -0,0 +1,39 @@ +import { svgIcon } from "@bitwarden/components"; + +export const TwoFactorAuthAuthenticatorIcon = svgIcon` + + + + + + + + + + + + + + + + + + +`; diff --git a/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-duo.icon.ts b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-duo.icon.ts new file mode 100644 index 00000000000..c81433d0fc1 --- /dev/null +++ b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-duo.icon.ts @@ -0,0 +1,20 @@ +import { svgIcon } from "@bitwarden/components"; + +export const TwoFactorAuthDuoIcon = svgIcon` + + + + + + + + + + + + + + +`; diff --git a/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-email.icon.ts b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-email.icon.ts new file mode 100644 index 00000000000..833ab3f8e98 --- /dev/null +++ b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-email.icon.ts @@ -0,0 +1,18 @@ +import { svgIcon } from "@bitwarden/components"; + +export const TwoFactorAuthEmailIcon = svgIcon` + + + + + + + + + + +`; diff --git a/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-security-key.icon.ts b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-security-key.icon.ts new file mode 100644 index 00000000000..f6ac90cfd5d --- /dev/null +++ b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-security-key.icon.ts @@ -0,0 +1,52 @@ +import { svgIcon } from "@bitwarden/components"; + +export const TwoFactorAuthSecurityKeyIcon = svgIcon` + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-webauthn.icon.ts b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-webauthn.icon.ts new file mode 100644 index 00000000000..233533fc807 --- /dev/null +++ b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-webauthn.icon.ts @@ -0,0 +1,40 @@ +import { svgIcon } from "@bitwarden/components"; + +export const TwoFactorAuthWebAuthnIcon = svgIcon` + + + + + + + + + + + + + + +`; diff --git a/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-yubico.icon.ts b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-yubico.icon.ts new file mode 100644 index 00000000000..6bb989a9d15 --- /dev/null +++ b/libs/auth/src/angular/icons/two-factor-auth/two-factor-auth-yubico.icon.ts @@ -0,0 +1,15 @@ +import { svgIcon } from "@bitwarden/components"; + +export const TwoFactorAuthYubicoIcon = svgIcon` + + + + + + + + + + +`; diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 67ab68852b2..bb2956b7569 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -72,5 +72,8 @@ export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.com export * from "./login-approval/login-approval.component"; export * from "./login-approval/default-login-approval-component.service"; +// two factor auth +export * from "./two-factor-auth"; + // device verification export * from "./new-device-verification/new-device-verification.component"; diff --git a/libs/auth/src/angular/two-factor-auth/child-components/index.ts b/libs/auth/src/angular/two-factor-auth/child-components/index.ts new file mode 100644 index 00000000000..429da3f14b3 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/index.ts @@ -0,0 +1,3 @@ +export * from "./two-factor-auth-email"; +export * from "./two-factor-auth-duo"; +export * from "./two-factor-auth-webauthn"; diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.html b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.html new file mode 100644 index 00000000000..af3a8569efa --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.html @@ -0,0 +1,6 @@ + + + {{ "verificationCode" | i18n }} + + + diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.ts similarity index 64% rename from libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.ts rename to libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.ts index bdf69f7420f..a4986d086b2 100644 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.ts @@ -1,12 +1,9 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { DialogModule } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, Output } from "@angular/core"; -import { ReactiveFormsModule, FormsModule } from "@angular/forms"; +import { Component, Input } from "@angular/core"; +import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { ButtonModule, LinkModule, @@ -31,9 +28,8 @@ import { AsyncActionsModule, FormsModule, ], - providers: [I18nPipe], + providers: [], }) export class TwoFactorAuthAuthenticatorComponent { - tokenValue: string; - @Output() token = new EventEmitter(); + @Input({ required: true }) tokenFormControl: FormControl | undefined = undefined; } diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/index.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/index.ts new file mode 100644 index 00000000000..c43325e0d0b --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/index.ts @@ -0,0 +1 @@ +export * from "./two-factor-auth-duo-component.service"; diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo-component.service.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo-component.service.ts new file mode 100644 index 00000000000..73aac55135c --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo-component.service.ts @@ -0,0 +1,32 @@ +import { Observable } from "rxjs"; + +export interface Duo2faResult { + code: string; + state: string; + /** + * The code and the state joined by a | character. + */ + token: string; +} + +/** + * A service which manages all the cross client logic for the duo 2FA component. + */ +export abstract class TwoFactorAuthDuoComponentService { + /** + * Retrieves the result of the duo two-factor authentication process. + * @returns {Observable} An observable that emits the result of the duo two-factor authentication process. + */ + abstract listenForDuo2faResult$(): Observable; + + /** + * Launches the client specific duo frameless 2FA flow. + */ + abstract launchDuoFrameless(duoFramelessUrl: string): Promise; + + /** + * Optionally launches the extension duo 2FA single action popout + * Only applies to the extension today. + */ + abstract openTwoFactorAuthDuoPopout?(): Promise; +} diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo.component.ts new file mode 100644 index 00000000000..80f84e872a0 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo.component.ts @@ -0,0 +1,96 @@ +import { DialogModule } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ReactiveFormsModule, FormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +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"; + +import { DuoLaunchAction } from "../../two-factor-auth-component.service"; + +import { + Duo2faResult, + TwoFactorAuthDuoComponentService, +} from "./two-factor-auth-duo-component.service"; + +@Component({ + standalone: true, + selector: "app-two-factor-auth-duo", + template: "", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + LinkModule, + TypographyModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + FormsModule, + ], + providers: [], +}) +export class TwoFactorAuthDuoComponent implements OnInit { + @Output() tokenEmitter = new EventEmitter(); + @Input() providerData: any; + + duoFramelessUrl: string | undefined = undefined; + + constructor( + protected i18nService: I18nService, + protected platformUtilsService: PlatformUtilsService, + protected toastService: ToastService, + private twoFactorAuthDuoComponentService: TwoFactorAuthDuoComponentService, + private destroyRef: DestroyRef, + ) {} + + async ngOnInit(): Promise { + this.twoFactorAuthDuoComponentService + .listenForDuo2faResult$() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((duo2faResult: Duo2faResult) => { + this.tokenEmitter.emit(duo2faResult.token); + }); + + // flow must be launched by user so they can choose to remember the device or not. + this.duoFramelessUrl = this.providerData.AuthUrl; + } + + // Called via parent two-factor-auth component. + async launchDuoFrameless(duoLaunchAction: DuoLaunchAction): Promise { + switch (duoLaunchAction) { + case DuoLaunchAction.DIRECT_LAUNCH: + await this.launchDuoFramelessDirectly(); + break; + case DuoLaunchAction.SINGLE_ACTION_POPOUT: + await this.twoFactorAuthDuoComponentService.openTwoFactorAuthDuoPopout?.(); + break; + default: + break; + } + } + + private async launchDuoFramelessDirectly(): Promise { + if (this.duoFramelessUrl === null || this.duoFramelessUrl === undefined) { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("duoHealthCheckResultsInNullAuthUrlError"), + }); + return; + } + + await this.twoFactorAuthDuoComponentService.launchDuoFrameless(this.duoFramelessUrl); + } +} diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/default-two-factor-auth-email-component.service.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/default-two-factor-auth-email-component.service.ts new file mode 100644 index 00000000000..caae13acc38 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/default-two-factor-auth-email-component.service.ts @@ -0,0 +1,6 @@ +import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-component.service"; + +export class DefaultTwoFactorAuthEmailComponentService + implements TwoFactorAuthEmailComponentService { + // no default implementation +} diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/index.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/index.ts new file mode 100644 index 00000000000..91f11b0b7dd --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/index.ts @@ -0,0 +1,2 @@ +export * from "./default-two-factor-auth-email-component.service"; +export * from "./two-factor-auth-email-component.service"; diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email-component.service.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email-component.service.ts new file mode 100644 index 00000000000..fa96b6b96c2 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email-component.service.ts @@ -0,0 +1,10 @@ +/** + * A service that manages all cross client functionality for the email 2FA component. + */ +export abstract class TwoFactorAuthEmailComponentService { + /** + * Optionally shows a warning to the user that they might need to popout the + * window to complete email 2FA. + */ + abstract openPopoutIfApprovedForEmail2fa?(): Promise; +} diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.html b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.html new file mode 100644 index 00000000000..41873c32ed0 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.html @@ -0,0 +1,17 @@ + + {{ "verificationCode" | i18n }} + + + + diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-email.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts similarity index 61% rename from libs/angular/src/auth/components/two-factor-auth/two-factor-auth-email.component.ts rename to libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts index 8f01403cdbb..1b6ed7e2bb4 100644 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-email.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts @@ -1,12 +1,9 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { DialogModule } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, OnInit, Output } from "@angular/core"; -import { ReactiveFormsModule, FormsModule } from "@angular/forms"; +import { Component, Input, OnInit } from "@angular/core"; +import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; @@ -25,6 +22,8 @@ import { ToastService, } from "@bitwarden/components"; +import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-component.service"; + @Component({ standalone: true, selector: "app-two-factor-auth-email", @@ -41,13 +40,13 @@ import { AsyncActionsModule, FormsModule, ], - providers: [I18nPipe], + providers: [], }) export class TwoFactorAuthEmailComponent implements OnInit { - @Output() token = new EventEmitter(); + @Input({ required: true }) tokenFormControl: FormControl | undefined = undefined; - twoFactorEmail: string = null; - emailPromise: Promise; + twoFactorEmail: string | undefined = undefined; + emailPromise: Promise | undefined = undefined; tokenValue: string = ""; constructor( @@ -59,25 +58,41 @@ export class TwoFactorAuthEmailComponent implements OnInit { protected apiService: ApiService, protected appIdService: AppIdService, private toastService: ToastService, + private twoFactorAuthEmailComponentService: TwoFactorAuthEmailComponentService, ) {} async ngOnInit(): Promise { - const providerData = await this.twoFactorService.getProviders().then((providers) => { - return providers.get(TwoFactorProviderType.Email); - }); - this.twoFactorEmail = providerData.Email; + await this.twoFactorAuthEmailComponentService.openPopoutIfApprovedForEmail2fa?.(); - if ((await this.twoFactorService.getProviders()).size > 1) { + const providers = await this.twoFactorService.getProviders(); + + if (!providers) { + throw new Error("User has no 2FA Providers"); + } + + const email2faProviderData = providers.get(TwoFactorProviderType.Email); + + if (!email2faProviderData) { + throw new Error("Unable to retrieve email 2FA provider data"); + } + + this.twoFactorEmail = email2faProviderData.Email; + + if (providers.size > 1) { await this.sendEmail(false); } } async sendEmail(doToast: boolean) { - if (this.emailPromise != null) { + if (this.emailPromise !== undefined) { return; } - if ((await this.loginStrategyService.getEmail()) == null) { + // TODO: PM-17545 - consider building a method on the login strategy service to get a mostly + // initialized TwoFactorEmailRequest in 1 call instead of 5 like we do today. + const email = await this.loginStrategyService.getEmail(); + + if (email == null) { this.toastService.showToast({ variant: "error", title: this.i18nService.t("errorOccurred"), @@ -88,19 +103,20 @@ export class TwoFactorAuthEmailComponent implements OnInit { try { const request = new TwoFactorEmailRequest(); - request.email = await this.loginStrategyService.getEmail(); - request.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash(); + request.email = email; + + request.masterPasswordHash = (await this.loginStrategyService.getMasterPasswordHash()) ?? ""; request.ssoEmail2FaSessionToken = - await this.loginStrategyService.getSsoEmail2FaSessionToken(); + (await this.loginStrategyService.getSsoEmail2FaSessionToken()) ?? ""; request.deviceIdentifier = await this.appIdService.getAppId(); - request.authRequestAccessCode = await this.loginStrategyService.getAccessCode(); - request.authRequestId = await this.loginStrategyService.getAuthRequestId(); + request.authRequestAccessCode = (await this.loginStrategyService.getAccessCode()) ?? ""; + request.authRequestId = (await this.loginStrategyService.getAuthRequestId()) ?? ""; this.emailPromise = this.apiService.postTwoFactorEmail(request); await this.emailPromise; if (doToast) { this.toastService.showToast({ variant: "success", - title: null, + title: "", message: this.i18nService.t("verificationCodeEmailSent", this.twoFactorEmail), }); } @@ -108,6 +124,6 @@ export class TwoFactorAuthEmailComponent implements OnInit { this.logService.error(e); } - this.emailPromise = null; + this.emailPromise = undefined; } } diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/default-two-factor-auth-webauthn-component.service.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/default-two-factor-auth-webauthn-component.service.ts new file mode 100644 index 00000000000..3d3578c656e --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/default-two-factor-auth-webauthn-component.service.ts @@ -0,0 +1,12 @@ +import { TwoFactorAuthWebAuthnComponentService } from "./two-factor-auth-webauthn-component.service"; + +export class DefaultTwoFactorAuthWebAuthnComponentService + implements TwoFactorAuthWebAuthnComponentService +{ + /** + * Default implementation is to not open in a new tab. + */ + shouldOpenWebAuthnInNewTab(): boolean { + return false; + } +} diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/index.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/index.ts new file mode 100644 index 00000000000..154566280c7 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/index.ts @@ -0,0 +1,2 @@ +export * from "./two-factor-auth-webauthn-component.service"; +export * from "./default-two-factor-auth-webauthn-component.service"; diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn-component.service.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn-component.service.ts new file mode 100644 index 00000000000..49842c53796 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn-component.service.ts @@ -0,0 +1,10 @@ +/** + * A service that manages all cross client functionality for the WebAuthn 2FA component. + */ +export abstract class TwoFactorAuthWebAuthnComponentService { + /** + * Determines if the WebAuthn 2FA should be opened in a new tab or can be completed in the current tab. + * In a browser extension context, we open WebAuthn in a new web client tab. + */ + abstract shouldOpenWebAuthnInNewTab(): boolean; +} diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component.html b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component.html new file mode 100644 index 00000000000..6f13b0a1fe2 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component.html @@ -0,0 +1,24 @@ +
+
+ +
+ + +
+ + + diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component.ts similarity index 61% rename from libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.ts rename to libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component.ts index ba3b645c68d..4987b41707b 100644 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { DialogModule } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Inject, OnDestroy, OnInit, Output } from "@angular/core"; @@ -8,14 +6,13 @@ import { ActivatedRoute } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { WebAuthnIFrame } from "@bitwarden/common/auth/webauthn-iframe"; -import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ButtonModule, @@ -26,6 +23,13 @@ import { ToastService, } from "@bitwarden/components"; +import { TwoFactorAuthWebAuthnComponentService } from "./two-factor-auth-webauthn-component.service"; + +export interface WebAuthnResult { + token: string; + remember?: boolean; +} + @Component({ standalone: true, selector: "app-two-factor-auth-webauthn", @@ -42,15 +46,16 @@ import { AsyncActionsModule, FormsModule, ], - providers: [I18nPipe], + providers: [], }) export class TwoFactorAuthWebAuthnComponent implements OnInit, OnDestroy { - @Output() token = new EventEmitter(); + @Output() webAuthnResultEmitter = new EventEmitter(); + @Output() webAuthnInNewTabEmitter = new EventEmitter(); webAuthnReady = false; webAuthnNewTab = false; webAuthnSupported = false; - webAuthn: WebAuthnIFrame = null; + webAuthnIframe: WebAuthnIFrame | undefined = undefined; constructor( protected i18nService: I18nService, @@ -60,33 +65,47 @@ export class TwoFactorAuthWebAuthnComponent implements OnInit, OnDestroy { protected twoFactorService: TwoFactorService, protected route: ActivatedRoute, private toastService: ToastService, + private twoFactorAuthWebAuthnComponentService: TwoFactorAuthWebAuthnComponentService, + private logService: LogService, ) { this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win); - - if (this.platformUtilsService.getClientType() == ClientType.Browser) { - // FIXME: Chromium 110 has broken WebAuthn support in extensions via an iframe - this.webAuthnNewTab = true; - } + this.webAuthnNewTab = this.twoFactorAuthWebAuthnComponentService.shouldOpenWebAuthnInNewTab(); } async ngOnInit(): Promise { - if (this.route.snapshot.paramMap.has("webAuthnResponse")) { - this.token.emit(this.route.snapshot.paramMap.get("webAuthnResponse")); + this.webAuthnInNewTabEmitter.emit(this.webAuthnNewTab); + + if (this.webAuthnNewTab && this.route.snapshot.paramMap.has("webAuthnResponse")) { + this.submitWebAuthnNewTabResponse(); + } else { + await this.buildWebAuthnIFrame(); } + } - this.cleanupWebAuthn(); + private submitWebAuthnNewTabResponse() { + const webAuthnNewTabResponse = this.route.snapshot.paramMap.get("webAuthnResponse"); + const remember = this.route.snapshot.paramMap.get("remember") === "true"; + if (webAuthnNewTabResponse != null) { + this.webAuthnResultEmitter.emit({ + token: webAuthnNewTabResponse, + remember, + }); + } + } + + private async buildWebAuthnIFrame() { if (this.win != null && this.webAuthnSupported) { const env = await firstValueFrom(this.environmentService.environment$); const webVaultUrl = env.getWebVaultUrl(); - this.webAuthn = new WebAuthnIFrame( + this.webAuthnIframe = new WebAuthnIFrame( this.win, webVaultUrl, this.webAuthnNewTab, this.platformUtilsService, this.i18nService, (token: string) => { - this.token.emit(token); + this.webAuthnResultEmitter.emit({ token }); }, (error: string) => { this.toastService.showToast({ @@ -111,25 +130,30 @@ export class TwoFactorAuthWebAuthnComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.cleanupWebAuthn(); + this.cleanupWebAuthnIframe(); } async authWebAuthn() { - const providerData = (await this.twoFactorService.getProviders()).get( - TwoFactorProviderType.WebAuthn, - ); + const providers = await this.twoFactorService.getProviders(); - if (!this.webAuthnSupported || this.webAuthn == null) { + if (providers == null) { + this.logService.error("No 2FA providers found. Unable to authenticate with WebAuthn."); return; } - this.webAuthn.init(providerData); + const providerData = providers?.get(TwoFactorProviderType.WebAuthn); + + if (!this.webAuthnSupported || this.webAuthnIframe == null) { + return; + } + + this.webAuthnIframe.init(providerData); } - private cleanupWebAuthn() { - if (this.webAuthn != null) { - this.webAuthn.stop(); - this.webAuthn.cleanup(); + private cleanupWebAuthnIframe() { + if (this.webAuthnIframe != null) { + this.webAuthnIframe.stop(); + this.webAuthnIframe.cleanup(); } } } diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-yubikey.component.html b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-yubikey.component.html new file mode 100644 index 00000000000..f0387bbcb52 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-yubikey.component.html @@ -0,0 +1,4 @@ + + {{ "verificationCode" | i18n }} + + diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-yubikey.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-yubikey.component.ts similarity index 69% rename from libs/angular/src/auth/components/two-factor-auth/two-factor-auth-yubikey.component.ts rename to libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-yubikey.component.ts index 71e8508f8ce..a1f4306b9a8 100644 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-yubikey.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-yubikey.component.ts @@ -1,10 +1,9 @@ import { DialogModule } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, Output } from "@angular/core"; -import { ReactiveFormsModule, FormsModule } from "@angular/forms"; +import { Component, Input } from "@angular/core"; +import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { ButtonModule, LinkModule, @@ -29,9 +28,8 @@ import { AsyncActionsModule, FormsModule, ], - providers: [I18nPipe], + providers: [], }) export class TwoFactorAuthYubikeyComponent { - tokenValue: string = ""; - @Output() token = new EventEmitter(); + @Input({ required: true }) tokenFormControl: FormControl | undefined = undefined; } diff --git a/libs/auth/src/angular/two-factor-auth/default-two-factor-auth-component.service.ts b/libs/auth/src/angular/two-factor-auth/default-two-factor-auth-component.service.ts new file mode 100644 index 00000000000..f68c1d34515 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/default-two-factor-auth-component.service.ts @@ -0,0 +1,19 @@ +import { + DuoLaunchAction, + LegacyKeyMigrationAction, + TwoFactorAuthComponentService, +} from "./two-factor-auth-component.service"; + +export class DefaultTwoFactorAuthComponentService implements TwoFactorAuthComponentService { + shouldCheckForWebAuthnQueryParamResponse() { + return false; + } + + determineLegacyKeyMigrationAction() { + return LegacyKeyMigrationAction.PREVENT_LOGIN_AND_SHOW_REQUIRE_MIGRATION_WARNING; + } + + determineDuoLaunchAction(): DuoLaunchAction { + return DuoLaunchAction.DIRECT_LAUNCH; + } +} diff --git a/libs/auth/src/angular/two-factor-auth/index.ts b/libs/auth/src/angular/two-factor-auth/index.ts new file mode 100644 index 00000000000..c5dc7b1a59d --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/index.ts @@ -0,0 +1,6 @@ +export * from "./two-factor-auth-component.service"; +export * from "./default-two-factor-auth-component.service"; +export * from "./two-factor-auth.component"; +export * from "./two-factor-auth.guard"; + +export * from "./child-components"; diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth-component.service.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth-component.service.ts new file mode 100644 index 00000000000..2bb354a8cc3 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth-component.service.ts @@ -0,0 +1,66 @@ +import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; + +export enum LegacyKeyMigrationAction { + PREVENT_LOGIN_AND_SHOW_REQUIRE_MIGRATION_WARNING, + NAVIGATE_TO_MIGRATION_COMPONENT, +} + +export enum DuoLaunchAction { + DIRECT_LAUNCH, + SINGLE_ACTION_POPOUT, +} + +/** + * Manages all cross client functionality so we can have a single two factor auth component + * implementation for all clients. + */ +export abstract class TwoFactorAuthComponentService { + /** + * Determines if the client should check for a webauthn response on init. + * Currently, only the extension should check during component initialization. + */ + abstract shouldCheckForWebAuthnQueryParamResponse(): boolean; + + /** + * Extends the popup width if required. + * Some client specific situations require the popup to be wider than the default width. + */ + abstract extendPopupWidthIfRequired?( + selected2faProviderType: TwoFactorProviderType, + ): Promise; + + /** + * Removes the popup width extension. + */ + abstract removePopupWidthExtension?(): void; + + /** + * We used to use the user's master key to encrypt their data. We deprecated that approach + * and now use a user key. This method should be called if we detect that the user + * is still using the old master key encryption scheme (server sends down a flag to + * indicate this). This method then determines what action to take based on the client. + * + * We have two possible actions: + * 1. Prevent the user from logging in and show a warning that they need to migrate their key on the web client today. + * 2. Navigate the user to the key migration component on the web client. + */ + abstract determineLegacyKeyMigrationAction(): LegacyKeyMigrationAction; + + /** + * Optionally closes any single action popouts (extension only). + * @returns true if we are in a single action popout and it was closed, false otherwise. + */ + abstract closeSingleActionPopouts?(): Promise; + + /** + * Optionally refreshes any open windows (exempts current window). + * Only defined on the extension client for the goal of refreshing sidebars. + */ + abstract reloadOpenWindows?(): void; + + /** + * Determines the action to take when launching the Duo flow. + * The extension has to popout the flow, while other clients can launch it directly. + */ + abstract determineDuoLaunchAction(): DuoLaunchAction; +} diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.html b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.html new file mode 100644 index 00000000000..ec03944a954 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.html @@ -0,0 +1,108 @@ + +
+ +
+
+ + +
+ + + + + + + + {{ "dontAskAgainOnThisDeviceFor30Days" | i18n }} + + + + + + +

{{ "noTwoStepProviders" | i18n }}

+

{{ "noTwoStepProviders2" | i18n }}

+
+ + +
+ + + + +

{{ "or" | i18n }}

+ + + + +
+ +
diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.spec.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts similarity index 74% rename from libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.spec.ts rename to libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts index 755813a677a..79856157aaa 100644 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.spec.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts @@ -1,3 +1,5 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ActivatedRoute, convertToParamMap, Router } from "@angular/router"; @@ -13,22 +15,20 @@ import { FakeTrustedDeviceUserDecryptionOption as TrustedDeviceUserDecryptionOption, FakeUserDecryptionOptions as UserDecryptionOptions, UserDecryptionOptionsServiceAbstraction, + LoginSuccessHandlerService, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { - Environment, - EnvironmentService, -} from "@bitwarden/common/platform/abstractions/environment.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -37,22 +37,16 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp import { UserId } from "@bitwarden/common/types/guid"; import { DialogService, ToastService } from "@bitwarden/components"; +import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; + +import { TwoFactorAuthComponentService } from "./two-factor-auth-component.service"; import { TwoFactorAuthComponent } from "./two-factor-auth.component"; -// test component that extends the TwoFactorAuthComponent @Component({}) class TestTwoFactorComponent extends TwoFactorAuthComponent {} -interface TwoFactorComponentProtected { - trustedDeviceEncRoute: string; - changePasswordRoute: string; - forcePasswordResetRoute: string; - successRoute: string; -} - -describe("TwoFactorComponent", () => { +describe("TwoFactorAuthComponent", () => { let component: TestTwoFactorComponent; - let _component: TwoFactorComponentProtected; let fixture: ComponentFixture; const userId = "userId" as UserId; @@ -64,7 +58,6 @@ describe("TwoFactorComponent", () => { let mockApiService: MockProxy; let mockPlatformUtilsService: MockProxy; let mockWin: MockProxy; - let mockEnvironmentService: MockProxy; let mockStateService: MockProxy; let mockLogService: MockProxy; let mockTwoFactorService: MockProxy; @@ -72,11 +65,14 @@ describe("TwoFactorComponent", () => { let mockLoginEmailService: MockProxy; let mockUserDecryptionOptionsService: MockProxy; let mockSsoLoginService: MockProxy; - let mockConfigService: MockProxy; let mockMasterPasswordService: FakeMasterPasswordService; let mockAccountService: FakeAccountService; let mockDialogService: MockProxy; let mockToastService: MockProxy; + let mockTwoFactorAuthCompService: MockProxy; + let anonLayoutWrapperDataService: MockProxy; + let mockEnvService: MockProxy; + let mockLoginSuccessHandlerService: MockProxy; let mockUserDecryptionOpts: { noMasterPassword: UserDecryptionOptions; @@ -98,10 +94,6 @@ describe("TwoFactorComponent", () => { mockApiService = mock(); mockPlatformUtilsService = mock(); mockWin = mock(); - const mockEnvironment = mock(); - mockEnvironment.getWebVaultUrl.mockReturnValue("http://example.com"); - mockEnvironmentService = mock(); - mockEnvironmentService.environment$ = new BehaviorSubject(mockEnvironment); mockStateService = mock(); mockLogService = mock(); @@ -110,11 +102,16 @@ describe("TwoFactorComponent", () => { mockLoginEmailService = mock(); mockUserDecryptionOptionsService = mock(); mockSsoLoginService = mock(); - mockConfigService = mock(); mockAccountService = mockAccountServiceWith(userId); mockMasterPasswordService = new FakeMasterPasswordService(); mockDialogService = mock(); mockToastService = mock(); + mockTwoFactorAuthCompService = mock(); + + mockEnvService = mock(); + mockLoginSuccessHandlerService = mock(); + + anonLayoutWrapperDataService = mock(); mockUserDecryptionOpts = { noMasterPassword: new UserDecryptionOptions({ @@ -159,7 +156,7 @@ describe("TwoFactorComponent", () => { }), }; - selectedUserDecryptionOptions = new BehaviorSubject(null); + selectedUserDecryptionOptions = new BehaviorSubject(undefined); mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions; TestBed.configureTestingModule({ @@ -171,7 +168,6 @@ describe("TwoFactorComponent", () => { { provide: ApiService, useValue: mockApiService }, { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, { provide: WINDOW, useValue: mockWin }, - { provide: EnvironmentService, useValue: mockEnvironmentService }, { provide: StateService, useValue: mockStateService }, { provide: ActivatedRoute, @@ -191,17 +187,19 @@ describe("TwoFactorComponent", () => { useValue: mockUserDecryptionOptionsService, }, { provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService }, - { provide: ConfigService, useValue: mockConfigService }, { provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, { provide: AccountService, useValue: mockAccountService }, { provide: DialogService, useValue: mockDialogService }, { provide: ToastService, useValue: mockToastService }, + { provide: TwoFactorAuthComponentService, useValue: mockTwoFactorAuthCompService }, + { provide: EnvironmentService, useValue: mockEnvService }, + { provide: AnonLayoutWrapperDataService, useValue: anonLayoutWrapperDataService }, + { provide: LoginSuccessHandlerService, useValue: mockLoginSuccessHandlerService }, ], }); fixture = TestBed.createComponent(TestTwoFactorComponent); component = fixture.componentInstance; - _component = component as any; }); afterEach(() => { @@ -217,13 +215,13 @@ describe("TwoFactorComponent", () => { const testChangePasswordOnSuccessfulLogin = () => { it("navigates to the component's defined change password route when user doesn't have a MP and key connector isn't enabled", async () => { // Act - await component.submit(); + await component.submit("testToken"); // Assert expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], { + expect(mockRouter.navigate).toHaveBeenCalledWith(["set-password"], { queryParams: { - identifier: component.orgIdentifier, + identifier: component.orgSsoIdentifier, }, }); }); @@ -232,12 +230,12 @@ describe("TwoFactorComponent", () => { const testForceResetOnSuccessfulLogin = (reasonString: string) => { it(`navigates to the component's defined forcePasswordResetRoute route when response.forcePasswordReset is ${reasonString}`, async () => { // Act - await component.submit(); + await component.submit("testToken"); // expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], { + expect(mockRouter.navigate).toHaveBeenCalledWith(["update-temp-password"], { queryParams: { - identifier: component.orgIdentifier, + identifier: component.orgSsoIdentifier, }, }); }); @@ -247,14 +245,14 @@ describe("TwoFactorComponent", () => { describe("submit", () => { const token = "testToken"; const remember = false; - const captchaToken = "testCaptchaToken"; + const currentAuthTypeSubject = new BehaviorSubject( + AuthenticationType.Password, + ); beforeEach(() => { - component.token = token; - component.remember = remember; - component.captchaToken = captchaToken; - selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword); + + mockLoginStrategyService.currentAuthType$ = currentAuthTypeSubject.asObservable(); }); it("calls authService.logInTwoFactor with correct parameters when form is submitted", async () => { @@ -262,50 +260,15 @@ describe("TwoFactorComponent", () => { mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); // Act - await component.submit(); + await component.submit(token, remember); // Assert expect(mockLoginStrategyService.logInTwoFactor).toHaveBeenCalledWith( new TokenTwoFactorRequest(component.selectedProviderType, token, remember), - captchaToken, + "", ); }); - it("should return when handleCaptchaRequired returns true", async () => { - // Arrange - const captchaSiteKey = "testCaptchaSiteKey"; - const authResult = new AuthResult(); - authResult.captchaSiteKey = captchaSiteKey; - - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); - - // Note: the any casts are required b/c typescript cant recognize that - // handleCaptureRequired is a method on TwoFactorComponent b/c it is inherited - // from the CaptchaProtectedComponent - const handleCaptchaRequiredSpy = jest - .spyOn(component, "handleCaptchaRequired") - .mockReturnValue(true); - - // Act - const result = await component.submit(); - - // Assert - expect(handleCaptchaRequiredSpy).toHaveBeenCalled(); - expect(result).toBeUndefined(); - }); - - it("calls onSuccessfulLogin when defined", async () => { - // Arrange - component.onSuccessfulLogin = jest.fn().mockResolvedValue(undefined); - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); - - // Act - await component.submit(); - - // Assert - expect(component.onSuccessfulLogin).toHaveBeenCalled(); - }); - it("calls loginEmailService.clearValues() when login is successful", async () => { // Arrange mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); @@ -313,7 +276,7 @@ describe("TwoFactorComponent", () => { const clearValuesSpy = jest.spyOn(mockLoginEmailService, "clearValues"); // Act - await component.submit(); + await component.submit(token, remember); // Assert expect(clearValuesSpy).toHaveBeenCalled(); @@ -339,11 +302,11 @@ describe("TwoFactorComponent", () => { mockUserDecryptionOpts.noMasterPasswordWithKeyConnector, ); - await component.submit(); + await component.submit(token, remember); - expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], { + expect(mockRouter.navigate).not.toHaveBeenCalledWith(["set-password"], { queryParams: { - identifier: component.orgIdentifier, + identifier: component.orgSsoIdentifier, }, }); }); @@ -369,30 +332,42 @@ describe("TwoFactorComponent", () => { }); }); - it("calls onSuccessfulLoginNavigate when the callback is defined", async () => { - // Arrange - component.onSuccessfulLoginNavigate = jest.fn().mockResolvedValue(undefined); + it("navigates to the component's defined success route (vault is default) when the login is successful", async () => { mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); // Act - await component.submit(); + await component.submit("testToken"); // Assert - expect(component.onSuccessfulLoginNavigate).toHaveBeenCalled(); - }); - - it("navigates to the component's defined success route when the login is successful and onSuccessfulLoginNavigate is undefined", async () => { - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); - - // Act - await component.submit(); - - // Assert - expect(component.onSuccessfulLoginNavigate).not.toBeDefined(); - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined); + expect(mockRouter.navigate).toHaveBeenCalledWith(["vault"], { + queryParams: { + identifier: component.orgSsoIdentifier, + }, + }); }); + + it.each([ + [AuthenticationType.Sso, "lock"], + [AuthenticationType.UserApiKey, "lock"], + ])( + "navigates to the lock component when the authentication type is %s", + async (authType, expectedRoute) => { + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); + currentAuthTypeSubject.next(authType); + + // Act + await component.submit("testToken"); + + // Assert + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith(["lock"], { + queryParams: { + identifier: component.orgSsoIdentifier, + }, + }); + }, + ); }); }); @@ -405,19 +380,8 @@ describe("TwoFactorComponent", () => { describe("submit", () => { const token = "testToken"; const remember = false; - const captchaToken = "testCaptchaToken"; - - beforeEach(() => { - component.token = token; - component.remember = remember; - component.captchaToken = captchaToken; - }); describe("Trusted Device Encryption scenarios", () => { - beforeEach(() => { - mockConfigService.getFeatureFlag.mockResolvedValue(true); - }); - describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => { beforeEach(() => { selectedUserDecryptionOptions.next( @@ -425,12 +389,13 @@ describe("TwoFactorComponent", () => { ); const authResult = new AuthResult(); + authResult.userId = userId; mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); }); - it("navigates to the component's defined trusted device encryption route and sets correct flag when user doesn't have a MP and key connector isn't enabled", async () => { + it("navigates to the login-initiated route and sets correct flag when user doesn't have a MP and key connector isn't enabled", async () => { // Act - await component.submit(); + await component.submit(token, remember); // Assert expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( @@ -439,10 +404,7 @@ describe("TwoFactorComponent", () => { ); expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith( - [_component.trustedDeviceEncRoute], - undefined, - ); + expect(mockRouter.navigate).toHaveBeenCalledWith(["login-initiated"]); }); }); @@ -480,23 +442,11 @@ describe("TwoFactorComponent", () => { mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); }); - it("navigates to the component's defined trusted device encryption route when login is successful and onSuccessfulLoginTdeNavigate is undefined", async () => { - await component.submit(); + it("navigates to the login-initiated route when login is successful", async () => { + await component.submit(token, remember); expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith( - [_component.trustedDeviceEncRoute], - undefined, - ); - }); - - it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => { - component.onSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(undefined); - - await component.submit(); - - expect(mockRouter.navigate).not.toHaveBeenCalled(); - expect(component.onSuccessfulLoginTdeNavigate).toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith(["login-initiated"]); }); }); }); diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts new file mode 100644 index 00000000000..296316198b0 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts @@ -0,0 +1,591 @@ +import { CommonModule } from "@angular/common"; +import { + Component, + DestroyRef, + ElementRef, + Inject, + OnDestroy, + OnInit, + ViewChild, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; +import { lastValueFrom, firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { + LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, + TrustedDeviceUserDecryptionOption, + UserDecryptionOptions, + LoginSuccessHandlerService, +} from "@bitwarden/auth/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; +import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; +import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + AsyncActionsModule, + ButtonModule, + CheckboxModule, + DialogService, + FormFieldModule, + ToastService, +} from "@bitwarden/components"; + +import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; +import { + TwoFactorAuthAuthenticatorIcon, + TwoFactorAuthEmailIcon, + TwoFactorAuthWebAuthnIcon, + TwoFactorAuthSecurityKeyIcon, + TwoFactorAuthDuoIcon, +} from "../icons/two-factor-auth"; + +import { TwoFactorAuthAuthenticatorComponent } from "./child-components/two-factor-auth-authenticator.component"; +import { TwoFactorAuthDuoComponent } from "./child-components/two-factor-auth-duo/two-factor-auth-duo.component"; +import { TwoFactorAuthEmailComponent } from "./child-components/two-factor-auth-email/two-factor-auth-email.component"; +import { TwoFactorAuthWebAuthnComponent } from "./child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component"; +import { TwoFactorAuthYubikeyComponent } from "./child-components/two-factor-auth-yubikey.component"; +import { + DuoLaunchAction, + LegacyKeyMigrationAction, + TwoFactorAuthComponentService, +} from "./two-factor-auth-component.service"; +import { + TwoFactorOptionsComponent, + TwoFactorOptionsDialogResult, +} from "./two-factor-options.component"; + +@Component({ + standalone: true, + selector: "app-two-factor-auth", + templateUrl: "two-factor-auth.component.html", + imports: [ + CommonModule, + JslibModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + RouterLink, + CheckboxModule, + ButtonModule, + TwoFactorOptionsComponent, // used as dialog + TwoFactorAuthAuthenticatorComponent, + TwoFactorAuthEmailComponent, + TwoFactorAuthDuoComponent, + TwoFactorAuthYubikeyComponent, + TwoFactorAuthWebAuthnComponent, + ], + providers: [], +}) +export class TwoFactorAuthComponent implements OnInit, OnDestroy { + @ViewChild("continueButton", { read: ElementRef, static: false }) continueButton: + | ElementRef + | undefined = undefined; + + loading = true; + + orgSsoIdentifier: string | undefined = undefined; + + providerType = TwoFactorProviderType; + selectedProviderType: TwoFactorProviderType = TwoFactorProviderType.Authenticator; + + // TODO: PM-17176 - build more specific type for 2FA metadata + twoFactorProviders: Map | null = null; + selectedProviderData: { [key: string]: string } | undefined; + + @ViewChild("duoComponent") duoComponent!: TwoFactorAuthDuoComponent; + + form = this.formBuilder.group({ + token: [ + "", + { + validators: [Validators.required], + updateOn: "submit", + }, + ], + remember: [false], + }); + + get tokenFormControl() { + return this.form.controls.token; + } + + get rememberFormControl() { + return this.form.controls.remember; + } + + formPromise: Promise | undefined; + + duoLaunchAction: DuoLaunchAction | undefined = undefined; + DuoLaunchAction = DuoLaunchAction; + + webAuthInNewTab = false; + + private authenticationSessionTimeoutRoute = "authentication-timeout"; + + constructor( + private loginStrategyService: LoginStrategyServiceAbstraction, + private router: Router, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private dialogService: DialogService, + private activatedRoute: ActivatedRoute, + private logService: LogService, + private twoFactorService: TwoFactorService, + private loginEmailService: LoginEmailServiceAbstraction, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private ssoLoginService: SsoLoginServiceAbstraction, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, + private accountService: AccountService, + private formBuilder: FormBuilder, + @Inject(WINDOW) protected win: Window, + private toastService: ToastService, + private twoFactorAuthComponentService: TwoFactorAuthComponentService, + private destroyRef: DestroyRef, + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private environmentService: EnvironmentService, + private loginSuccessHandlerService: LoginSuccessHandlerService, + ) {} + + async ngOnInit() { + this.orgSsoIdentifier = + this.activatedRoute.snapshot.queryParamMap.get("identifier") ?? undefined; + + this.listenForAuthnSessionTimeout(); + + await this.setSelected2faProviderType(); + await this.set2faProvidersAndData(); + await this.setAnonLayoutDataByTwoFactorProviderType(); + + await this.twoFactorAuthComponentService.extendPopupWidthIfRequired?.( + this.selectedProviderType, + ); + + this.duoLaunchAction = this.twoFactorAuthComponentService.determineDuoLaunchAction(); + + this.loading = false; + } + + private async setSelected2faProviderType() { + const webAuthnSupported = this.platformUtilsService.supportsWebAuthn(this.win); + + if ( + this.twoFactorAuthComponentService.shouldCheckForWebAuthnQueryParamResponse() && + webAuthnSupported + ) { + const webAuthn2faResponse = + this.activatedRoute.snapshot.queryParamMap.get("webAuthnResponse"); + if (webAuthn2faResponse) { + this.selectedProviderType = TwoFactorProviderType.WebAuthn; + return; + } + } + + this.selectedProviderType = await this.twoFactorService.getDefaultProvider(webAuthnSupported); + } + + private async set2faProvidersAndData() { + this.twoFactorProviders = await this.twoFactorService.getProviders(); + const providerData = this.twoFactorProviders?.get(this.selectedProviderType); + this.selectedProviderData = providerData; + } + + private listenForAuthnSessionTimeout() { + this.loginStrategyService.authenticationSessionTimeout$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(async (expired) => { + if (!expired) { + return; + } + + try { + await this.router.navigate([this.authenticationSessionTimeoutRoute]); + } catch (err) { + this.logService.error( + `Failed to navigate to ${this.authenticationSessionTimeoutRoute} route`, + err, + ); + } + }); + } + + submit = async (token?: string, remember?: boolean) => { + // 2FA submission either comes via programmatic submission for flows like + // WebAuthn or Duo, or via the form submission for other 2FA providers. + // So, we have to figure out whether we need to validate the form or not. + let tokenValue: string; + if (token !== undefined) { + if (token === "" || token === null) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("verificationCodeRequired"), + }); + return; + } + + // Token has been passed in so no need to validate the form + tokenValue = token; + } else { + // We support programmatic submission via enter key press, but we only update on submit + // so we have to manually update the form here for the invalid check to be accurate. + this.tokenFormControl.markAsTouched(); + this.tokenFormControl.markAsDirty(); + this.tokenFormControl.updateValueAndValidity(); + + // Token has not been passed in ensure form is valid before proceeding. + if (this.form.invalid) { + // returning as form validation will show the relevant errors. + return; + } + + // This shouldn't be possible w/ the required form validation, but + // to satisfy strict TS checks, have to check for null here. + const tokenFormValue = this.tokenFormControl.value; + + if (!tokenFormValue) { + return; + } + + tokenValue = tokenFormValue.trim(); + } + + // In all flows but WebAuthn, the remember value is taken from the form. + const rememberValue = remember ?? this.rememberFormControl.value ?? false; + + try { + this.formPromise = this.loginStrategyService.logInTwoFactor( + new TokenTwoFactorRequest(this.selectedProviderType, tokenValue, rememberValue), + "", // TODO: PM-15162 - deprecate captchaResponse + ); + const authResult: AuthResult = await this.formPromise; + this.logService.info("Successfully submitted two factor token"); + await this.handleAuthResult(authResult); + } catch { + this.logService.error("Error submitting two factor token"); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidVerificationCode"), + }); + } + }; + + async selectOtherTwoFactorMethod() { + const dialogRef = TwoFactorOptionsComponent.open(this.dialogService); + const response: TwoFactorOptionsDialogResult | string | undefined = await lastValueFrom( + dialogRef.closed, + ); + + if (response !== undefined && response !== null && typeof response !== "string") { + const providerData = await this.twoFactorService.getProviders().then((providers) => { + return providers?.get(response.type); + }); + this.selectedProviderData = providerData; + this.selectedProviderType = response.type; + await this.setAnonLayoutDataByTwoFactorProviderType(); + + this.form.reset(); + this.form.updateValueAndValidity(); + } + } + + async launchDuo() { + if (this.duoComponent != null && this.duoLaunchAction !== undefined) { + await this.duoComponent.launchDuoFrameless(this.duoLaunchAction); + } + } + + protected async handleMigrateEncryptionKey(result: AuthResult): Promise { + if (!result.requiresEncryptionKeyMigration) { + return false; + } + // Migration is forced so prevent login via return + const legacyKeyMigrationAction: LegacyKeyMigrationAction = + this.twoFactorAuthComponentService.determineLegacyKeyMigrationAction(); + + switch (legacyKeyMigrationAction) { + case LegacyKeyMigrationAction.NAVIGATE_TO_MIGRATION_COMPONENT: + await this.router.navigate(["migrate-legacy-encryption"]); + break; + case LegacyKeyMigrationAction.PREVENT_LOGIN_AND_SHOW_REQUIRE_MIGRATION_WARNING: + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccured"), + message: this.i18nService.t("encryptionKeyMigrationRequired"), + }); + break; + } + return true; + } + + async setAnonLayoutDataByTwoFactorProviderType() { + switch (this.selectedProviderType) { + case TwoFactorProviderType.Authenticator: + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageSubtitle: this.i18nService.t("enterTheCodeFromYourAuthenticatorApp"), + pageIcon: TwoFactorAuthAuthenticatorIcon, + }); + break; + case TwoFactorProviderType.Email: + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageSubtitle: this.i18nService.t("enterTheCodeSentToYourEmail"), + pageIcon: TwoFactorAuthEmailIcon, + }); + break; + case TwoFactorProviderType.Duo: + case TwoFactorProviderType.OrganizationDuo: + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageSubtitle: this.i18nService.t("duoTwoFactorRequiredPageSubtitle"), + pageIcon: TwoFactorAuthDuoIcon, + }); + break; + case TwoFactorProviderType.Yubikey: + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageSubtitle: this.i18nService.t("pressYourYubiKeyToAuthenticate"), + pageIcon: TwoFactorAuthSecurityKeyIcon, + }); + break; + case TwoFactorProviderType.WebAuthn: + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageSubtitle: this.i18nService.t("followTheStepsBelowToFinishLoggingIn"), + pageIcon: TwoFactorAuthWebAuthnIcon, + }); + break; + default: + this.logService.error( + "setAnonLayoutDataByTwoFactorProviderType: Unhandled 2FA provider type", + this.selectedProviderType, + ); + break; + } + } + + private async handleAuthResult(authResult: AuthResult) { + if (await this.handleMigrateEncryptionKey(authResult)) { + return; // stop login process + } + + // User is fully logged in so handle any post login logic before executing navigation + await this.loginSuccessHandlerService.run(authResult.userId); + this.loginEmailService.clearValues(); + + // Save off the OrgSsoIdentifier for use in the TDE flows + // - TDE login decryption options component + // - Browser SSO on extension open + if (this.orgSsoIdentifier !== undefined) { + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier( + this.orgSsoIdentifier, + userId, + ); + } + + // note: this flow affects both TDE & standard users + if (this.isForcePasswordResetRequired(authResult)) { + return await this.handleForcePasswordReset(this.orgSsoIdentifier); + } + + const userDecryptionOpts = await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptions$, + ); + + const tdeEnabled = await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption); + + if (tdeEnabled) { + return await this.handleTrustedDeviceEncryptionEnabled(authResult.userId, userDecryptionOpts); + } + + // User must set password if they don't have one and they aren't using either TDE or key connector. + const requireSetPassword = + !userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined; + + if (requireSetPassword || authResult.resetMasterPassword) { + // Change implies going no password -> password in this case + return await this.handleChangePasswordRequired(this.orgSsoIdentifier); + } + + this.twoFactorAuthComponentService.reloadOpenWindows?.(); + + const inSingleActionPopoutWhichWasClosed = + await this.twoFactorAuthComponentService.closeSingleActionPopouts?.(); + + if (inSingleActionPopoutWhichWasClosed) { + // No need to execute navigation as the single action popout was closed + return; + } + + const defaultSuccessRoute = await this.determineDefaultSuccessRoute(); + + await this.router.navigate([defaultSuccessRoute], { + queryParams: { + identifier: this.orgSsoIdentifier, + }, + }); + } + + private async determineDefaultSuccessRoute(): Promise { + const authType = await firstValueFrom(this.loginStrategyService.currentAuthType$); + if (authType == AuthenticationType.Sso || authType == AuthenticationType.UserApiKey) { + return "lock"; + } + + return "vault"; + } + + private async isTrustedDeviceEncEnabled( + trustedDeviceOption: TrustedDeviceUserDecryptionOption | undefined, + ): Promise { + const ssoTo2faFlowActive = this.activatedRoute.snapshot.queryParamMap.get("sso") === "true"; + + return ssoTo2faFlowActive && trustedDeviceOption !== undefined; + } + + private async handleTrustedDeviceEncryptionEnabled( + userId: UserId, + userDecryptionOpts: UserDecryptionOptions, + ): Promise { + // Tde offboarding takes precedence + if ( + !userDecryptionOpts.hasMasterPassword && + userDecryptionOpts.trustedDeviceOption?.isTdeOffboarding + ) { + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.TdeOffboarding, + userId, + ); + } else if ( + !userDecryptionOpts.hasMasterPassword && + userDecryptionOpts.trustedDeviceOption?.hasManageResetPasswordPermission + ) { + // If user doesn't have a MP, but has reset password permission, they must set a MP + + // Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device) + // Note: we cannot directly navigate to the set password screen in this scenario as we are in a pre-decryption state, and + // if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key. + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, + ); + } + + this.twoFactorAuthComponentService.reloadOpenWindows?.(); + + const inSingleActionPopoutWhichWasClosed = + await this.twoFactorAuthComponentService.closeSingleActionPopouts?.(); + + if (inSingleActionPopoutWhichWasClosed) { + // No need to execute navigation as the single action popout was closed + return; + } + + await this.router.navigate(["login-initiated"]); + } + + private async handleChangePasswordRequired(orgIdentifier: string | undefined) { + await this.router.navigate(["set-password"], { + queryParams: { + identifier: orgIdentifier, + }, + }); + } + + /** + * Determines if a user needs to reset their password based on certain conditions. + * Users can be forced to reset their password via an admin or org policy disallowing weak passwords. + * Note: this is different from the SSO component login flow as a user can + * login with MP and then have to pass 2FA to finish login and we can actually + * evaluate if they have a weak password at that time. + * + * @param {AuthResult} authResult - The authentication result. + * @returns {boolean} Returns true if a password reset is required, false otherwise. + */ + private isForcePasswordResetRequired(authResult: AuthResult): boolean { + const forceResetReasons = [ + ForceSetPasswordReason.AdminForcePasswordReset, + ForceSetPasswordReason.WeakMasterPassword, + ]; + + return forceResetReasons.includes(authResult.forcePasswordReset); + } + + private async handleForcePasswordReset(orgIdentifier: string | undefined) { + await this.router.navigate(["update-temp-password"], { + queryParams: { + identifier: orgIdentifier, + }, + }); + } + + showContinueButton() { + return ( + this.selectedProviderType != null && + this.selectedProviderType !== TwoFactorProviderType.WebAuthn && + this.selectedProviderType !== TwoFactorProviderType.Duo && + this.selectedProviderType !== TwoFactorProviderType.OrganizationDuo + ); + } + + hideRememberMe() { + // Don't show remember for me for scenarios where we have to popout the extension + return ( + ((this.selectedProviderType === TwoFactorProviderType.Duo || + this.selectedProviderType === TwoFactorProviderType.OrganizationDuo) && + this.duoLaunchAction === DuoLaunchAction.SINGLE_ACTION_POPOUT) || + (this.selectedProviderType === TwoFactorProviderType.WebAuthn && this.webAuthInNewTab) + ); + } + + async use2faRecoveryCode() { + // TODO: PM-17696 eventually we should have a consolidated recover-2fa component as a follow up + // so that we don't have to always open a new tab for non-web clients. + const env = await firstValueFrom(this.environmentService.environment$); + const webVault = env.getWebVaultUrl(); + this.platformUtilsService.launchUri(webVault + "/#/recover-2fa"); + } + + async handleEnterKeyPress() { + // Each 2FA provider has a different implementation. + // For example, email 2FA uses an input of type "text" for the token which does not automatically submit on enter. + // Yubikey, however, uses an input with type "password" which does automatically submit on enter. + // So we have to handle the enter key press differently for each provider. + switch (this.selectedProviderType) { + case TwoFactorProviderType.Authenticator: + case TwoFactorProviderType.Email: + // We must actually submit the form via click in order for the tokenFormControl value to be set. + this.continueButton?.nativeElement?.click(); + break; + case TwoFactorProviderType.Duo: + case TwoFactorProviderType.OrganizationDuo: + case TwoFactorProviderType.WebAuthn: + case TwoFactorProviderType.Yubikey: + // Do nothing + break; + default: + this.logService.error( + "handleEnterKeyPress: Unhandled 2FA provider type", + this.selectedProviderType, + ); + break; + } + } + + async ngOnDestroy() { + this.twoFactorAuthComponentService.removePopupWidthExtension?.(); + } +} diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.spec.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.spec.ts new file mode 100644 index 00000000000..22cfe0820ef --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.spec.ts @@ -0,0 +1,74 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { provideRouter, Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; + +import { LoginStrategyServiceAbstraction } from "../../common"; + +import { TwoFactorAuthGuard } from "./two-factor-auth.guard"; + +@Component({ template: "" }) +export class EmptyComponent {} + +describe("TwoFactorAuthGuard", () => { + let loginStrategyService: MockProxy; + const currentAuthTypesSubject = new BehaviorSubject(null); + + let twoFactorService: MockProxy; + let router: Router; + + beforeEach(() => { + loginStrategyService = mock(); + loginStrategyService.currentAuthType$ = currentAuthTypesSubject.asObservable(); + + twoFactorService = mock(); + + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { path: "login", component: EmptyComponent }, + { path: "protected", component: EmptyComponent, canActivate: [TwoFactorAuthGuard] }, + ]), + { provide: LoginStrategyServiceAbstraction, useValue: loginStrategyService }, + { provide: TwoFactorService, useValue: twoFactorService }, + ], + }); + + router = TestBed.inject(Router); + }); + + it("should redirect to /login if the user is not authenticating", async () => { + // Arrange + currentAuthTypesSubject.next(null); + twoFactorService.getProviders.mockResolvedValue(null); + + // Act + await router.navigateByUrl("/protected"); + + // Assert + expect(router.url).toBe("/login"); + }); + + const authenticationTypes = Object.entries(AuthenticationType) + // filter out reverse mappings (e.g., "0": "Password") + .filter(([key, value]) => typeof value === "number") + .map(([key, value]) => [value, key]) as [AuthenticationType, string][]; + + authenticationTypes.forEach(([authType, authTypeName]) => { + it(`should redirect to /login if the user is authenticating with ${authTypeName} but no two-factor providers exist`, async () => { + // Arrange + currentAuthTypesSubject.next(authType); + twoFactorService.getProviders.mockResolvedValue(null); + + // Act + await router.navigateByUrl("/protected"); + + // Assert + expect(router.url).toBe("/login"); + }); + }); +}); diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.ts new file mode 100644 index 00000000000..2aec0bae441 --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.ts @@ -0,0 +1,33 @@ +import { inject } from "@angular/core"; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, + UrlTree, +} from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; + +import { LoginStrategyServiceAbstraction } from "../../common"; + +export const TwoFactorAuthGuard: CanActivateFn = async ( + route: ActivatedRouteSnapshot, + routerState: RouterStateSnapshot, +): Promise => { + const loginStrategyService = inject(LoginStrategyServiceAbstraction); + const twoFactorService = inject(TwoFactorService); + const router = inject(Router); + + const currentAuthType = await firstValueFrom(loginStrategyService.currentAuthType$); + const userIsAuthenticating = currentAuthType !== null; + + const twoFactorProviders = await twoFactorService.getProviders(); + + if (!userIsAuthenticating || twoFactorProviders == null) { + return router.createUrlTree(["/login"]); + } + + return true; +}; diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html new file mode 100644 index 00000000000..277ba047add --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html @@ -0,0 +1,50 @@ + + + {{ "selectTwoStepLoginMethod" | i18n }} + + + + + + + + + + + + diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts new file mode 100644 index 00000000000..819a48d0d8c --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts @@ -0,0 +1,81 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + TwoFactorProviderDetails, + TwoFactorService, +} from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; +import { + ButtonModule, + DialogModule, + DialogService, + IconModule, + ItemModule, + TypographyModule, +} from "@bitwarden/components"; + +import { + TwoFactorAuthAuthenticatorIcon, + TwoFactorAuthDuoIcon, + TwoFactorAuthEmailIcon, + TwoFactorAuthWebAuthnIcon, + TwoFactorAuthYubicoIcon, +} from "../icons/two-factor-auth"; + +export type TwoFactorOptionsDialogResult = { + type: TwoFactorProviderType; +}; + +@Component({ + standalone: true, + selector: "app-two-factor-options", + templateUrl: "two-factor-options.component.html", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + TypographyModule, + ItemModule, + IconModule, + ], + providers: [], +}) +export class TwoFactorOptionsComponent implements OnInit { + providers: TwoFactorProviderDetails[] = []; + TwoFactorProviderType = TwoFactorProviderType; + + readonly Icons = { + TwoFactorAuthAuthenticatorIcon, + TwoFactorAuthEmailIcon, + TwoFactorAuthDuoIcon, + TwoFactorAuthYubicoIcon, + TwoFactorAuthWebAuthnIcon, + }; + + constructor( + private twoFactorService: TwoFactorService, + private dialogRef: DialogRef, + ) {} + + async ngOnInit() { + const providers = await this.twoFactorService.getSupportedProviders(window); + providers.sort((a: TwoFactorProviderDetails, b: TwoFactorProviderDetails) => a.sort - b.sort); + this.providers = providers; + } + + async choose(p: TwoFactorProviderDetails) { + this.dialogRef.close({ type: p.type }); + } + + static open(dialogService: DialogService) { + return dialogService.open(TwoFactorOptionsComponent); + } + + cancel() { + this.dialogRef.close(); + } +} diff --git a/libs/auth/src/common/abstractions/login-strategy.service.ts b/libs/auth/src/common/abstractions/login-strategy.service.ts index bd725f29024..e9fa780b0fe 100644 --- a/libs/auth/src/common/abstractions/login-strategy.service.ts +++ b/libs/auth/src/common/abstractions/login-strategy.service.ts @@ -66,6 +66,7 @@ export abstract class LoginStrategyServiceAbstraction { */ logInTwoFactor: ( twoFactor: TokenTwoFactorRequest, + // TODO: PM-15162 - deprecate captchaResponse captchaResponse: string, ) => Promise; /** diff --git a/libs/common/src/auth/abstractions/two-factor.service.ts b/libs/common/src/auth/abstractions/two-factor.service.ts index 00987dabd98..528e52bf5da 100644 --- a/libs/common/src/auth/abstractions/two-factor.service.ts +++ b/libs/common/src/auth/abstractions/two-factor.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { TwoFactorProviderType } from "../enums/two-factor-provider-type"; import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response"; @@ -11,15 +9,52 @@ export interface TwoFactorProviderDetails { sort: number; premium: boolean; } - export abstract class TwoFactorService { - init: () => void; - getSupportedProviders: (win: Window) => Promise; - getDefaultProvider: (webAuthnSupported: boolean) => Promise; - setSelectedProvider: (type: TwoFactorProviderType) => Promise; - clearSelectedProvider: () => Promise; + /** + * Initializes the client-side's TwoFactorProviders const with translations. + */ + abstract init(): void; - setProviders: (response: IdentityTwoFactorResponse) => Promise; - clearProviders: () => Promise; - getProviders: () => Promise>; + /** + * Gets a list of two-factor providers from state that are supported on the current client. + * E.g., WebAuthn and Duo are not available on all clients. + * @returns A list of supported two-factor providers or an empty list if none are stored in state. + */ + abstract getSupportedProviders(win: Window): Promise; + + /** + * Gets the previously selected two-factor provider or the default two factor provider based on priority. + * @param webAuthnSupported - Whether or not WebAuthn is supported by the client. Prevents WebAuthn from being the default provider if false. + */ + abstract getDefaultProvider(webAuthnSupported: boolean): Promise; + + /** + * Sets the selected two-factor provider in state. + * @param type - The type of two-factor provider to set as the selected provider. + */ + abstract setSelectedProvider(type: TwoFactorProviderType): Promise; + + /** + * Clears the selected two-factor provider from state. + */ + abstract clearSelectedProvider(): Promise; + + /** + * Sets the list of available two-factor providers in state. + * @param response - the response from Identity for when 2FA is required. Includes the list of available 2FA providers. + */ + abstract setProviders(response: IdentityTwoFactorResponse): Promise; + + /** + * Clears the list of available two-factor providers from state. + */ + abstract clearProviders(): Promise; + + /** + * Gets the list of two-factor providers from state. + * Note: no filtering is done here, so this will return all providers, including potentially + * unsupported ones for the current client. + * @returns A list of two-factor providers or null if none are stored in state. + */ + abstract getProviders(): Promise | null>; } diff --git a/libs/common/src/auth/services/two-factor.service.ts b/libs/common/src/auth/services/two-factor.service.ts index 3826ffaaa22..83e113268a2 100644 --- a/libs/common/src/auth/services/two-factor.service.ts +++ b/libs/common/src/auth/services/two-factor.service.ts @@ -206,7 +206,7 @@ export class TwoFactorService implements TwoFactorServiceAbstraction { await this.providersState.update(() => null); } - getProviders(): Promise> { + getProviders(): Promise | null> { return firstValueFrom(this.providers$); } } diff --git a/libs/common/src/auth/webauthn-iframe.ts b/libs/common/src/auth/webauthn-iframe.ts index 1e360a53507..4bc278b9a65 100644 --- a/libs/common/src/auth/webauthn-iframe.ts +++ b/libs/common/src/auth/webauthn-iframe.ts @@ -25,7 +25,10 @@ export class WebAuthnIFrame { const params = new URLSearchParams({ data: this.base64Encode(JSON.stringify(data)), parent: encodeURIComponent(this.win.document.location.href), - btnText: encodeURIComponent(this.i18nService.t("webAuthnAuthenticate")), + btnText: encodeURIComponent(this.i18nService.t("readSecurityKey")), + btnAwaitingInteractionText: encodeURIComponent( + this.i18nService.t("awaitingSecurityKeyInteraction"), + ), v: "1", }); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 985d1453090..c5cd5dac636 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -32,7 +32,6 @@ export enum FeatureFlag { ExtensionRefresh = "extension-refresh", PersistPopupView = "persist-popup-view", PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service", - TwoFactorComponentRefactor = "two-factor-component-refactor", VaultBulkManagementAction = "vault-bulk-management-action", UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh", SSHKeyVaultItem = "ssh-key-vault-item", @@ -92,7 +91,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.ExtensionRefresh]: FALSE, [FeatureFlag.PersistPopupView]: FALSE, [FeatureFlag.PM4154_BulkEncryptionService]: FALSE, - [FeatureFlag.TwoFactorComponentRefactor]: FALSE, [FeatureFlag.VaultBulkManagementAction]: FALSE, [FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE, [FeatureFlag.SSHKeyVaultItem]: FALSE,